merge main
This commit is contained in:
commit
79ffbb7bc2
169 changed files with 8659 additions and 1466 deletions
32
.env.example
32
.env.example
|
|
@ -1,19 +1,25 @@
|
|||
HOST=127.0.0.1
|
||||
PORT=5000
|
||||
|
||||
# uvicorn variable, allow https behind a proxy
|
||||
# FORWARDED_ALLOW_IPS="*"
|
||||
|
||||
DEBUG=false
|
||||
|
||||
# Allow users and admins by user IDs (comma separated list)
|
||||
LNBITS_ALLOWED_USERS=""
|
||||
LNBITS_ADMIN_USERS=""
|
||||
# Extensions only admin can access
|
||||
LNBITS_ADMIN_EXTENSIONS="ngrok"
|
||||
LNBITS_DEFAULT_WALLET_NAME="LNbits wallet"
|
||||
|
||||
# csv ad image filepaths or urls, extensions can choose to honor
|
||||
LNBITS_AD_SPACE=""
|
||||
# Ad space description
|
||||
# LNBITS_AD_SPACE_TITLE="Supported by"
|
||||
# csv ad space, format "<url>;<img-light>;<img-dark>, <url>;<img-light>;<img-dark>", extensions can choose to honor
|
||||
# LNBITS_AD_SPACE=""
|
||||
|
||||
# Hides wallet api, extensions can choose to honor
|
||||
LNBITS_HIDE_API=false
|
||||
LNBITS_HIDE_API=false
|
||||
|
||||
# Disable extensions for all users, use "all" to disable all extensions
|
||||
LNBITS_DISABLED_EXTENSIONS="amilk"
|
||||
|
|
@ -39,11 +45,11 @@ STARTUP_INVOICE_EXPIRY_CHECK=True
|
|||
LNBITS_SITE_TITLE="LNbits"
|
||||
LNBITS_SITE_TAGLINE="free and open-source lightning wallet"
|
||||
LNBITS_SITE_DESCRIPTION="Some description about your service, will display if title is not 'LNbits'"
|
||||
# Choose from mint, flamingo, freedom, salvador, autumn, monochrome, classic
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, freedom, mint, autumn, monochrome, salvador"
|
||||
# Choose from bitcoin, mint, flamingo, freedom, salvador, autumn, monochrome, classic
|
||||
LNBITS_THEME_OPTIONS="classic, bitcoin, flamingo, freedom, mint, autumn, monochrome, salvador"
|
||||
# LNBITS_CUSTOM_LOGO="https://lnbits.com/assets/images/logo/logo.svg"
|
||||
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet
|
||||
# Choose from LNPayWallet, OpenNodeWallet, LntxbotWallet, ClicheWallet, LnTipsWallet
|
||||
# LndRestWallet, CoreLightningWallet, LNbitsWallet, SparkWallet, FakeWallet, EclairWallet
|
||||
LNBITS_BACKEND_WALLET_CLASS=VoidWallet
|
||||
# VoidWallet is just a fallback that works without any actual Lightning capabilities,
|
||||
|
|
@ -69,7 +75,7 @@ LNBITS_KEY=LNBITS_ADMIN_KEY
|
|||
LND_REST_ENDPOINT=https://127.0.0.1:8080/
|
||||
LND_REST_CERT="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/tls.cert"
|
||||
LND_REST_MACAROON="/home/bob/.config/Zap/lnd/bitcoin/mainnet/wallet-1/data/chain/bitcoin/mainnet/admin.macaroon or HEXSTRING"
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# To use an AES-encrypted macaroon, set
|
||||
# LND_REST_MACAROON_ENCRYPTED="eNcRyPtEdMaCaRoOn"
|
||||
|
||||
# LNPayWallet
|
||||
|
|
@ -93,4 +99,14 @@ LNBITS_DENOMINATION=sats
|
|||
|
||||
# EclairWallet
|
||||
ECLAIR_URL=http://127.0.0.1:8283
|
||||
ECLAIR_PASS=eclairpw
|
||||
ECLAIR_PASS=eclairpw
|
||||
|
||||
# LnTipsWallet
|
||||
# Enter /api in LightningTipBot to get your key
|
||||
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
|
||||
LNTIPS_API_ENDPOINT=https://ln.tips
|
||||
|
||||
# Cashu Mint
|
||||
# Use a long-enough random (!) private key.
|
||||
# Once set, you cannot change this key as for now.
|
||||
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"
|
||||
|
|
|
|||
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- LNbits version: [e.g. 0.9.2 or commit hash]
|
||||
- Database [e.g. sqlite, postgres]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[Feature request]"
|
||||
labels: feature request
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
10
.github/ISSUE_TEMPLATE/something-else.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/something-else.md
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: Something else
|
||||
about: Anything else that you need to say
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ RUN curl -sSL https://install.python-poetry.org | python3 -
|
|||
ENV PATH="/root/.local/bin:$PATH"
|
||||
|
||||
WORKDIR /app
|
||||
RUN mkdir -p lnbits/data
|
||||
|
||||
COPY . .
|
||||
|
||||
|
|
|
|||
44
docs/devs/websockets.md
Normal file
44
docs/devs/websockets.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
layout: default
|
||||
parent: For developers
|
||||
title: Websockets
|
||||
nav_order: 2
|
||||
---
|
||||
|
||||
|
||||
Websockets
|
||||
=================
|
||||
|
||||
`websockets` are a great way to add a two way instant data channel between server and client.
|
||||
|
||||
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
|
||||
|
||||
|
||||
Example vue-js function for listening to the websocket:
|
||||
|
||||
```
|
||||
initWs: async function () {
|
||||
if (location.protocol !== 'http:') {
|
||||
localUrl =
|
||||
'wss://' +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/api/v1/ws/' +
|
||||
self.item.id
|
||||
} else {
|
||||
localUrl =
|
||||
'ws://' +
|
||||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/api/v1/ws/' +
|
||||
self.item.id
|
||||
}
|
||||
this.ws = new WebSocket(localUrl)
|
||||
this.ws.addEventListener('message', async ({data}) => {
|
||||
const res = JSON.parse(data.toString())
|
||||
console.log(res)
|
||||
})
|
||||
},
|
||||
```
|
||||
|
|
@ -18,21 +18,25 @@ If you have problems installing LNbits using these instructions, please have a l
|
|||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
|
||||
# for making sure python 3.9 is installed, skip if installed
|
||||
# for making sure python 3.9 is installed, skip if installed. To check your installed version: python3 --version
|
||||
sudo apt update
|
||||
sudo apt install software-properties-common
|
||||
sudo add-apt-repository ppa:deadsnakes/ppa
|
||||
sudo apt install python3.9 python3.9-distutils
|
||||
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
export PATH="/home/ubuntu/.local/bin:$PATH" # or whatever is suggested in the poetry install notes printed to terminal
|
||||
# Once the above poetry install is completed, use the installation path printed to terminal and replace in the following command
|
||||
export PATH="/home/user/.local/bin:$PATH"
|
||||
# Next command, you can exchange with python3.10 or newer versions.
|
||||
# Identify your version with python3 --version and specify in the next line
|
||||
# command is only needed when your default python is not ^3.9 or ^3.10
|
||||
poetry env use python3.9
|
||||
poetry install --no-dev
|
||||
poetry run python build.py
|
||||
poetry install --only main
|
||||
|
||||
mkdir data
|
||||
cp .env.example .env
|
||||
nano .env # set funding source
|
||||
# set funding source amongst other options
|
||||
nano .env
|
||||
```
|
||||
|
||||
#### Running the server
|
||||
|
|
@ -40,9 +44,13 @@ nano .env # set funding source
|
|||
```sh
|
||||
poetry run lnbits
|
||||
# To change port/host pass 'poetry run lnbits --port 9000 --host 0.0.0.0'
|
||||
# adding --debug in the start-up command above to help your troubleshooting and generate a more verbose output
|
||||
# Note that you have to add the line DEBUG=true in your .env file, too.
|
||||
```
|
||||
|
||||
## Option 2: Nix
|
||||
## Option 2: Nix
|
||||
|
||||
> note: currently not supported while we make some architectural changes on the path to leave beta
|
||||
|
||||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
|
|
@ -67,8 +75,8 @@ LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT
|
|||
```sh
|
||||
git clone https://github.com/lnbits/lnbits-legend.git
|
||||
cd lnbits-legend/
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
|
||||
python3 -m venv venv
|
||||
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3.9-venv'
|
||||
python3.9 -m venv venv
|
||||
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
# create the data folder and the .env file
|
||||
|
|
@ -98,7 +106,7 @@ docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.en
|
|||
|
||||
## Option 5: Fly.io
|
||||
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNBits for free on Fly.io for personal use.
|
||||
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
|
||||
|
||||
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
|
||||
|
||||
|
|
@ -149,6 +157,7 @@ kill_timeout = 30
|
|||
HOST="127.0.0.1"
|
||||
PORT=5000
|
||||
LNBITS_FORCE_HTTPS=true
|
||||
FORWARDED_ALLOW_IPS="*"
|
||||
LNBITS_DATA_FOLDER="/data"
|
||||
|
||||
${PUT_YOUR_LNBITS_ENV_VARS_HERE}
|
||||
|
|
@ -160,7 +169,7 @@ kill_timeout = 30
|
|||
...
|
||||
```
|
||||
|
||||
Next, create a volume to store the sqlite database for LNBits. Be sure to choose the same region for the volume that you chose earlier.
|
||||
Next, create a volume to store the sqlite database for LNbits. Be sure to choose the same region for the volume that you chose earlier.
|
||||
|
||||
```
|
||||
fly volumes create lnbits_data --size 1
|
||||
|
|
@ -211,8 +220,8 @@ You need to edit the `.env` file.
|
|||
|
||||
```sh
|
||||
# add the database connection string to .env 'nano .env' LNBITS_DATABASE_URL=
|
||||
# postgres://<user>:<myPassword>@<host>/<lnbits> - alter line bellow with your user, password and db name
|
||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost/lnbits"
|
||||
# postgres://<user>:<myPassword>@<host>:<port>/<lnbits> - alter line bellow with your user, password and db name
|
||||
LNBITS_DATABASE_URL="postgres://postgres:postgres@localhost:5432/lnbits"
|
||||
# save and exit
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -79,3 +79,8 @@ For the invoice to work you must have a publicly accessible URL in your LNbits.
|
|||
- `LNBITS_BACKEND_WALLET_CLASS`: **OpenNodeWallet**
|
||||
- `OPENNODE_API_ENDPOINT`: https://api.opennode.com/
|
||||
- `OPENNODE_KEY`: opennodeAdminApiKey
|
||||
|
||||
|
||||
### Cliche Wallet
|
||||
|
||||
- `CLICHE_ENDPOINT`: ws://127.0.0.1:12000
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ def create_app(config_object="lnbits.settings") -> FastAPI:
|
|||
)
|
||||
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||
# app.add_middleware(ASGIProxyFix)
|
||||
|
||||
check_funding_source(app)
|
||||
register_assets(app)
|
||||
|
|
@ -126,7 +125,7 @@ def check_funding_source(app: FastAPI) -> None:
|
|||
logger.info("Retrying connection to backend in 5 seconds...")
|
||||
await asyncio.sleep(5)
|
||||
signal.signal(signal.SIGINT, original_sigint_handler)
|
||||
logger.info(
|
||||
logger.success(
|
||||
f"✔️ Backend {WALLET.__class__.__name__} connected and with a balance of {balance} msat."
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -65,8 +65,7 @@ async def migrate_databases():
|
|||
(db_name, version, version),
|
||||
)
|
||||
|
||||
async def run_migration(db, migrations_module):
|
||||
db_name = migrations_module.__name__.split(".")[-2]
|
||||
async def run_migration(db, migrations_module, db_name):
|
||||
for key, migrate in migrations_module.__dict__.items():
|
||||
match = match = matcher.match(key)
|
||||
if match:
|
||||
|
|
@ -97,20 +96,24 @@ async def migrate_databases():
|
|||
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
|
||||
current_versions = {row["db"]: row["version"] for row in rows}
|
||||
matcher = re.compile(r"^m(\d\d\d)_")
|
||||
await run_migration(conn, core_migrations)
|
||||
db_name = core_migrations.__name__.split(".")[-2]
|
||||
await run_migration(conn, core_migrations, db_name)
|
||||
|
||||
for ext in get_valid_extensions():
|
||||
try:
|
||||
ext_migrations = importlib.import_module(
|
||||
f"lnbits.extensions.{ext.code}.migrations"
|
||||
|
||||
module_str = (
|
||||
ext.migration_module or f"lnbits.extensions.{ext.code}.migrations"
|
||||
)
|
||||
ext_migrations = importlib.import_module(module_str)
|
||||
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
|
||||
db_name = ext.db_name or module_str.split(".")[-2]
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
f"Please make sure that the extension `{ext.code}` has a migrations file."
|
||||
)
|
||||
|
||||
async with ext_db.connect() as ext_conn:
|
||||
await run_migration(ext_conn, ext_migrations)
|
||||
await run_migration(ext_conn, ext_migrations, db_name)
|
||||
|
||||
logger.info("✔️ All migrations done.")
|
||||
|
|
|
|||
|
|
@ -229,6 +229,24 @@ async def get_wallet_payment(
|
|||
return Payment.from_row(row) if row else None
|
||||
|
||||
|
||||
async def get_latest_payments_by_extension(ext_name: str, ext_id: str, limit: int = 5):
|
||||
rows = await db.fetchall(
|
||||
f"""
|
||||
SELECT * FROM apipayments
|
||||
WHERE pending = 'false'
|
||||
AND extra LIKE ?
|
||||
AND extra LIKE ?
|
||||
ORDER BY time DESC LIMIT {limit}
|
||||
""",
|
||||
(
|
||||
f"%{ext_name}%",
|
||||
f"%{ext_id}%",
|
||||
),
|
||||
)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
async def get_payments(
|
||||
*,
|
||||
wallet_id: Optional[str] = None,
|
||||
|
|
@ -330,38 +348,6 @@ async def delete_expired_invoices(
|
|||
"""
|
||||
)
|
||||
|
||||
# # then we delete all expired invoices, checking one by one
|
||||
# rows = await (conn or db).fetchall(
|
||||
# f"""
|
||||
# SELECT bolt11
|
||||
# FROM apipayments
|
||||
# WHERE pending = true
|
||||
# AND bolt11 IS NOT NULL
|
||||
# AND amount > 0 AND time < {db.timestamp_now} - {db.interval_seconds(86400)}
|
||||
# """
|
||||
# )
|
||||
# logger.debug(f"Checking expiry of {len(rows)} invoices")
|
||||
# for (payment_request,) in rows:
|
||||
# try:
|
||||
# invoice = bolt11.decode(payment_request)
|
||||
# except:
|
||||
# continue
|
||||
|
||||
# expiration_date = datetime.datetime.fromtimestamp(invoice.date + invoice.expiry)
|
||||
# if expiration_date > datetime.datetime.utcnow():
|
||||
# continue
|
||||
# logger.debug(
|
||||
# f"Deleting expired invoice: {invoice.payment_hash} (expired: {expiration_date})"
|
||||
# )
|
||||
# await (conn or db).execute(
|
||||
# """
|
||||
# DELETE FROM apipayments
|
||||
# WHERE pending = true AND hash = ?
|
||||
# """,
|
||||
# (invoice.payment_hash,),
|
||||
# )
|
||||
|
||||
|
||||
# payments
|
||||
# --------
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ async def m001_initial(db):
|
|||
f"""
|
||||
CREATE TABLE IF NOT EXISTS apipayments (
|
||||
payhash TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
fee INTEGER NOT NULL DEFAULT 0,
|
||||
wallet TEXT NOT NULL,
|
||||
pending BOOLEAN NOT NULL,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import asyncio
|
|||
import json
|
||||
from binascii import unhexlify
|
||||
from io import BytesIO
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
from fastapi import Depends, WebSocket, WebSocketDisconnect
|
||||
from lnurl import LnurlErrorResponse
|
||||
from lnurl import decode as decode_lnurl # type: ignore
|
||||
from loguru import logger
|
||||
|
|
@ -382,3 +382,28 @@ async def check_transaction_status(
|
|||
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
|
||||
def fee_reserve(amount_msat: int) -> int:
|
||||
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
|
||||
|
||||
|
||||
class WebsocketConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
logger.debug(websocket)
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def send_data(self, message: str, item_id: str):
|
||||
for connection in self.active_connections:
|
||||
if connection.path_params["item_id"] == item_id:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
websocketManager = WebsocketConnectionManager()
|
||||
|
||||
|
||||
async def websocketUpdater(item_id, data):
|
||||
return await websocketManager.send_data(f"{data}", item_id)
|
||||
|
|
|
|||
|
|
@ -361,6 +361,35 @@ new Vue({
|
|||
this.receive.status = 'pending'
|
||||
})
|
||||
},
|
||||
onInitQR: async function (promise) {
|
||||
try {
|
||||
await promise
|
||||
} catch (error) {
|
||||
let mapping = {
|
||||
NotAllowedError: 'ERROR: you need to grant camera access permission',
|
||||
NotFoundError: 'ERROR: no camera on this device',
|
||||
NotSupportedError:
|
||||
'ERROR: secure context required (HTTPS, localhost)',
|
||||
NotReadableError: 'ERROR: is the camera already in use?',
|
||||
OverconstrainedError: 'ERROR: installed cameras are not suitable',
|
||||
StreamApiNotSupportedError:
|
||||
'ERROR: Stream API is not supported in this browser',
|
||||
InsecureContextError:
|
||||
'ERROR: Camera access is only permitted in secure context. Use HTTPS or localhost rather than HTTP.'
|
||||
}
|
||||
let valid_error = Object.keys(mapping).filter(key => {
|
||||
return error.name === key
|
||||
})
|
||||
let camera_error = valid_error
|
||||
? mapping[valid_error]
|
||||
: `ERROR: Camera error (${error.name})`
|
||||
this.parse.camera.show = false
|
||||
this.$q.notify({
|
||||
message: camera_error,
|
||||
type: 'negative'
|
||||
})
|
||||
}
|
||||
},
|
||||
decodeQR: function (res) {
|
||||
this.parse.data.request = res
|
||||
this.decodeRequest()
|
||||
|
|
|
|||
|
|
@ -171,7 +171,35 @@
|
|||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<a href="https://mynodebtc.com">
|
||||
<q-img
|
||||
contain
|
||||
:src="($q.dark.isActive) ? '/static/images/mynode.png' : '/static/images/mynodel.png'"
|
||||
></q-img>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col q-pl-md"> </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD = ADS.split(';') %}
|
||||
<div class="col-6 col-sm-4 col-md-8 q-gutter-y-sm">
|
||||
<q-btn flat color="secondary" class="full-width q-mb-md"
|
||||
>{{ AD_TITLE }}</q-btn
|
||||
>
|
||||
|
||||
<a href="{{ AD[0] }}" class="q-ma-md">
|
||||
<img
|
||||
v-if="($q.dark.isActive)"
|
||||
src="{{ AD[1] }}"
|
||||
style="max-width: 90%"
|
||||
/>
|
||||
<img v-else src="{{ AD[2] }}" style="max-width: 90%" />
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -388,9 +388,14 @@
|
|||
{% endif %} {% if AD_SPACE %} {% for ADS in AD_SPACE %} {% set AD =
|
||||
ADS.split(';') %}
|
||||
<q-card>
|
||||
<a href="{{ AD[0] }}"
|
||||
><img width="100%" src="{{ AD[1] }}"
|
||||
/></a> </q-card
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-mt-none q-mb-sm">{{ AD_TITLE }}</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<a href="{{ AD[0] }}" class="q-ma-md">
|
||||
<img v-if="($q.dark.isActive)" src="{{ AD[1] }}" />
|
||||
<img v-else src="{{ AD[2] }}" />
|
||||
</a> </q-card-section></q-card
|
||||
>{% endfor %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -653,6 +658,7 @@
|
|||
<q-responsive :ratio="1">
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
@init="onInitQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</q-responsive>
|
||||
|
|
@ -671,6 +677,7 @@
|
|||
<div class="text-center q-mb-lg">
|
||||
<qrcode-stream
|
||||
@decode="decodeQR"
|
||||
@init="onInitQR"
|
||||
class="rounded-borders"
|
||||
></qrcode-stream>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,15 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
|
|||
import async_timeout
|
||||
import httpx
|
||||
import pyqrcode
|
||||
from fastapi import Depends, Header, Query, Request
|
||||
from fastapi import (
|
||||
Depends,
|
||||
Header,
|
||||
Query,
|
||||
Request,
|
||||
Response,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.params import Body
|
||||
from loguru import logger
|
||||
|
|
@ -56,6 +64,8 @@ from ..services import (
|
|||
create_invoice,
|
||||
pay_invoice,
|
||||
perform_lnurlauth,
|
||||
websocketManager,
|
||||
websocketUpdater,
|
||||
)
|
||||
from ..tasks import api_invoice_listeners
|
||||
|
||||
|
|
@ -155,30 +165,29 @@ class CreateInvoiceData(BaseModel):
|
|||
|
||||
|
||||
async def api_payments_create_invoice(data: CreateInvoiceData, wallet: Wallet):
|
||||
if data.description_hash:
|
||||
if data.description_hash or data.unhashed_description:
|
||||
try:
|
||||
description_hash = binascii.unhexlify(data.description_hash)
|
||||
description_hash = (
|
||||
binascii.unhexlify(data.description_hash)
|
||||
if data.description_hash
|
||||
else b""
|
||||
)
|
||||
unhashed_description = (
|
||||
binascii.unhexlify(data.unhashed_description)
|
||||
if data.unhashed_description
|
||||
else b""
|
||||
)
|
||||
except binascii.Error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'description_hash' must be a valid hex string",
|
||||
detail="'description_hash' and 'unhashed_description' must be a valid hex strings",
|
||||
)
|
||||
unhashed_description = b""
|
||||
memo = ""
|
||||
elif data.unhashed_description:
|
||||
try:
|
||||
unhashed_description = binascii.unhexlify(data.unhashed_description)
|
||||
except binascii.Error:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="'unhashed_description' must be a valid hex string",
|
||||
)
|
||||
description_hash = b""
|
||||
memo = ""
|
||||
else:
|
||||
description_hash = b""
|
||||
unhashed_description = b""
|
||||
memo = data.memo or LNBITS_SITE_TITLE
|
||||
|
||||
if data.unit == "sat":
|
||||
amount = int(data.amount)
|
||||
else:
|
||||
|
|
@ -476,7 +485,7 @@ async def api_lnurlscan(code: str, wallet: WalletTypeInfo = Depends(get_key_type
|
|||
except:
|
||||
# parse internet identifier (user@domain.com)
|
||||
name_domain = code.split("@")
|
||||
if len(name_domain) == 2 and len(name_domain[1].split(".")) == 2:
|
||||
if len(name_domain) == 2 and len(name_domain[1].split(".")) >= 2:
|
||||
name, domain = name_domain
|
||||
url = (
|
||||
("http://" if domain.endswith(".onion") else "https://")
|
||||
|
|
@ -585,8 +594,8 @@ class DecodePayment(BaseModel):
|
|||
data: str
|
||||
|
||||
|
||||
@core_app.post("/api/v1/payments/decode")
|
||||
async def api_payments_decode(data: DecodePayment):
|
||||
@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK)
|
||||
async def api_payments_decode(data: DecodePayment, response: Response):
|
||||
payment_str = data.data
|
||||
try:
|
||||
if payment_str[:5] == "LNURL":
|
||||
|
|
@ -607,6 +616,7 @@ async def api_payments_decode(data: DecodePayment):
|
|||
"min_final_cltv_expiry": invoice.min_final_cltv_expiry,
|
||||
}
|
||||
except:
|
||||
response.status_code = HTTPStatus.BAD_REQUEST
|
||||
return {"message": "Failed to decode"}
|
||||
|
||||
|
||||
|
|
@ -697,3 +707,34 @@ async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
|
|||
"delta_msats": delta,
|
||||
"timestamp": int(time.time()),
|
||||
}
|
||||
|
||||
|
||||
##################UNIVERSAL WEBSOCKET MANAGER########################
|
||||
|
||||
|
||||
@core_app.websocket("/api/v1/ws/{item_id}")
|
||||
async def websocket_connect(websocket: WebSocket, item_id: str):
|
||||
await websocketManager.connect(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
websocketManager.disconnect(websocket)
|
||||
|
||||
|
||||
@core_app.post("/api/v1/ws/{item_id}")
|
||||
async def websocket_update_post(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except:
|
||||
return {"sent": False, "data": data}
|
||||
|
||||
|
||||
@core_app.get("/api/v1/ws/{item_id}/{data}")
|
||||
async def websocket_update_get(item_id: str, data: str):
|
||||
try:
|
||||
await websocketUpdater(item_id, data)
|
||||
return {"sent": True, "data": data}
|
||||
except:
|
||||
return {"sent": False, "data": data}
|
||||
|
|
|
|||
35
lnbits/db.py
35
lnbits/db.py
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
|
@ -52,6 +53,12 @@ class Compat:
|
|||
return ""
|
||||
return "<nothing>"
|
||||
|
||||
@property
|
||||
def big_int(self) -> str:
|
||||
if self.type in {POSTGRES}:
|
||||
return "BIGINT"
|
||||
return "INT"
|
||||
|
||||
|
||||
class Connection(Compat):
|
||||
def __init__(self, conn: AsyncConnection, txn, typ, name, schema):
|
||||
|
|
@ -67,18 +74,40 @@ class Connection(Compat):
|
|||
query = query.replace("?", "%s")
|
||||
return query
|
||||
|
||||
def rewrite_values(self, values):
|
||||
# strip html
|
||||
CLEANR = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});")
|
||||
|
||||
def cleanhtml(raw_html):
|
||||
if isinstance(raw_html, str):
|
||||
cleantext = re.sub(CLEANR, "", raw_html)
|
||||
return cleantext
|
||||
else:
|
||||
return raw_html
|
||||
|
||||
# tuple to list and back to tuple
|
||||
value_list = [values] if isinstance(values, str) else list(values)
|
||||
values = tuple([cleanhtml(l) for l in value_list])
|
||||
return values
|
||||
|
||||
async def fetchall(self, query: str, values: tuple = ()) -> list:
|
||||
result = await self.conn.execute(self.rewrite_query(query), values)
|
||||
result = await self.conn.execute(
|
||||
self.rewrite_query(query), self.rewrite_values(values)
|
||||
)
|
||||
return await result.fetchall()
|
||||
|
||||
async def fetchone(self, query: str, values: tuple = ()):
|
||||
result = await self.conn.execute(self.rewrite_query(query), values)
|
||||
result = await self.conn.execute(
|
||||
self.rewrite_query(query), self.rewrite_values(values)
|
||||
)
|
||||
row = await result.fetchone()
|
||||
await result.close()
|
||||
return row
|
||||
|
||||
async def execute(self, query: str, values: tuple = ()):
|
||||
return await self.conn.execute(self.rewrite_query(query), values)
|
||||
return await self.conn.execute(
|
||||
self.rewrite_query(query), self.rewrite_values(values)
|
||||
)
|
||||
|
||||
|
||||
class Database(Compat):
|
||||
|
|
|
|||
|
|
@ -95,4 +95,4 @@ async def api_bleskomat_delete(
|
|||
)
|
||||
|
||||
await delete_bleskomat(bleskomat_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ This extension allows you to link your Bolt Card (or other compatible NXP NTAG d
|
|||
|
||||
**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!***
|
||||
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNBits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
|
||||
|
||||
## About the keys
|
||||
|
||||
|
|
@ -25,12 +25,12 @@ So far, regarding the keys, the app can only write a new key set on an empty car
|
|||
|
||||
- Read the card with the app. Note UID so you can fill it in the extension later.
|
||||
- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}`
|
||||
- `{external_id}` should be replaced with the External ID found in the LNBits dialog.
|
||||
- `{external_id}` should be replaced with the External ID found in the LNbits dialog.
|
||||
|
||||
- Add new card in the extension.
|
||||
- Set a max sats per transaction. Any transaction greater than this amount will be rejected.
|
||||
- Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected.
|
||||
- Set a card name. This is just for your reference inside LNBits.
|
||||
- Set a card name. This is just for your reference inside LNbits.
|
||||
- Set the card UID. This is the unique identifier on your NFC card and is 7 bytes.
|
||||
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
|
||||
- Advanced Options
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ async def m001_initial(db):
|
|||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE boltcards.hits (
|
||||
id TEXT PRIMARY KEY UNIQUE,
|
||||
card_id TEXT NOT NULL,
|
||||
|
|
@ -38,7 +38,7 @@ async def m001_initial(db):
|
|||
useragent TEXT,
|
||||
old_ctr INT NOT NULL DEFAULT 0,
|
||||
new_ctr INT NOT NULL DEFAULT 0,
|
||||
amount INT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
|
|
@ -47,11 +47,11 @@ async def m001_initial(db):
|
|||
)
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE boltcards.refunds (
|
||||
id TEXT PRIMARY KEY UNIQUE,
|
||||
hit_id TEXT NOT NULL,
|
||||
refund_amount INT NOT NULL,
|
||||
refund_amount {db.big_int} NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
|
|
|
|||
|
|
@ -380,7 +380,11 @@
|
|||
<strong>Lock key:</strong> {{ qrCodeDialog.data.k0 }}<br />
|
||||
<strong>Meta key:</strong> {{ qrCodeDialog.data.k1 }}<br />
|
||||
<strong>File key:</strong> {{ qrCodeDialog.data.k2 }}<br />
|
||||
<br />
|
||||
Always backup all keys that you're trying to write on the card. Without
|
||||
them you may not be able to change them in the future!<br />
|
||||
</p>
|
||||
|
||||
<br />
|
||||
<q-btn
|
||||
unelevated
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ async def api_card_delete(card_id, wallet: WalletTypeInfo = Depends(require_admi
|
|||
raise HTTPException(detail="Not your card.", status_code=HTTPStatus.FORBIDDEN)
|
||||
|
||||
await delete_card(card_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@boltcards_ext.get("/api/v1/hits")
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
async def m001_initial(db):
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE boltz.submarineswap (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
payment_hash TEXT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
boltz_id TEXT NOT NULL,
|
||||
refund_address TEXT NOT NULL,
|
||||
refund_privkey TEXT NOT NULL,
|
||||
expected_amount INT NOT NULL,
|
||||
expected_amount {db.big_int} NOT NULL,
|
||||
timeout_block_height INT NOT NULL,
|
||||
address TEXT NOT NULL,
|
||||
bip21 TEXT NOT NULL,
|
||||
|
|
@ -22,12 +22,12 @@ async def m001_initial(db):
|
|||
"""
|
||||
)
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE boltz.reverse_submarineswap (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
onchain_address TEXT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
instant_settlement BOOLEAN NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
boltz_id TEXT NOT NULL,
|
||||
|
|
@ -37,7 +37,7 @@ async def m001_initial(db):
|
|||
claim_privkey TEXT NOT NULL,
|
||||
lockup_address TEXT NOT NULL,
|
||||
invoice TEXT NOT NULL,
|
||||
onchain_amount INT NOT NULL,
|
||||
onchain_amount {db.big_int} NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ async def check_for_pending_swaps():
|
|||
swap_status = get_swap_status(swap)
|
||||
# should only happen while development when regtest is reset
|
||||
if swap_status.exists is False:
|
||||
logger.warning(f"Boltz - swap: {swap.boltz_id} does not exist.")
|
||||
logger.debug(f"Boltz - swap: {swap.boltz_id} does not exist.")
|
||||
await update_swap_status(swap.id, "failed")
|
||||
continue
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ async def check_for_pending_swaps():
|
|||
else:
|
||||
if swap_status.hit_timeout:
|
||||
if not swap_status.has_lockup:
|
||||
logger.warning(
|
||||
logger.debug(
|
||||
f"Boltz - swap: {swap.id} hit timeout, but no lockup tx..."
|
||||
)
|
||||
await update_swap_status(swap.id, "timeout")
|
||||
|
|
|
|||
11
lnbits/extensions/cashu/README.md
Normal file
11
lnbits/extensions/cashu/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Cashu
|
||||
|
||||
## Create ecash mint for pegging in/out of ecash
|
||||
|
||||
|
||||
|
||||
### Usage
|
||||
|
||||
1. Enable extension
|
||||
2. Create a Mint
|
||||
3. Share wallet
|
||||
48
lnbits/extensions/cashu/__init__.py
Normal file
48
lnbits/extensions/cashu/__init__.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import asyncio
|
||||
|
||||
from environs import Env # type: ignore
|
||||
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_cashu")
|
||||
|
||||
import sys
|
||||
|
||||
cashu_static_files = [
|
||||
{
|
||||
"path": "/cashu/static",
|
||||
"app": StaticFiles(directory="lnbits/extensions/cashu/static"),
|
||||
"name": "cashu_static",
|
||||
}
|
||||
]
|
||||
from cashu.mint.ledger import Ledger
|
||||
|
||||
env = Env()
|
||||
env.read_env()
|
||||
|
||||
ledger = Ledger(
|
||||
db=db,
|
||||
seed=env.str("CASHU_PRIVATE_KEY", default="SuperSecretPrivateKey"),
|
||||
derivation_path="0/0/0/1",
|
||||
)
|
||||
|
||||
cashu_ext: APIRouter = APIRouter(prefix="/cashu", tags=["cashu"])
|
||||
|
||||
|
||||
def cashu_renderer():
|
||||
return template_renderer(["lnbits/extensions/cashu/templates"])
|
||||
|
||||
|
||||
from .tasks import startup_cashu_mint, wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def cashu_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(startup_cashu_mint))
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
7
lnbits/extensions/cashu/config.json
Normal file
7
lnbits/extensions/cashu/config.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "Cashu",
|
||||
"short_description": "Ecash mint and wallet",
|
||||
"icon": "account_balance",
|
||||
"contributors": ["calle", "vlad", "arcbtc"],
|
||||
"hidden": false
|
||||
}
|
||||
63
lnbits/extensions/cashu/crud.py
Normal file
63
lnbits/extensions/cashu/crud.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import os
|
||||
import random
|
||||
import time
|
||||
from binascii import hexlify, unhexlify
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
from cashu.core.base import MintKeyset
|
||||
from embit import bip32, bip39, ec, script
|
||||
from embit.networks import NETWORKS
|
||||
from loguru import logger
|
||||
|
||||
from lnbits.db import Connection, Database
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from . import db
|
||||
from .models import Cashu, Pegs, Promises, Proof
|
||||
|
||||
|
||||
async def create_cashu(
|
||||
cashu_id: str, keyset_id: str, wallet_id: str, data: Cashu
|
||||
) -> Cashu:
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO cashu.cashu (id, wallet, name, tickershort, fraction, maxsats, coins, keyset_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
cashu_id,
|
||||
wallet_id,
|
||||
data.name,
|
||||
data.tickershort,
|
||||
data.fraction,
|
||||
data.maxsats,
|
||||
data.coins,
|
||||
keyset_id,
|
||||
),
|
||||
)
|
||||
|
||||
cashu = await get_cashu(cashu_id)
|
||||
assert cashu, "Newly created cashu couldn't be retrieved"
|
||||
return cashu
|
||||
|
||||
|
||||
async def get_cashu(cashu_id) -> Optional[Cashu]:
|
||||
row = await db.fetchone("SELECT * FROM cashu.cashu WHERE id = ?", (cashu_id,))
|
||||
return Cashu(**row) if row else None
|
||||
|
||||
|
||||
async def get_cashus(wallet_ids: Union[str, List[str]]) -> List[Cashu]:
|
||||
if isinstance(wallet_ids, str):
|
||||
wallet_ids = [wallet_ids]
|
||||
|
||||
q = ",".join(["?"] * len(wallet_ids))
|
||||
rows = await db.fetchall(
|
||||
f"SELECT * FROM cashu.cashu WHERE wallet IN ({q})", (*wallet_ids,)
|
||||
)
|
||||
|
||||
return [Cashu(**row) for row in rows]
|
||||
|
||||
|
||||
async def delete_cashu(cashu_id) -> None:
|
||||
await db.execute("DELETE FROM cashu.cashu WHERE id = ?", (cashu_id,))
|
||||
33
lnbits/extensions/cashu/migrations.py
Normal file
33
lnbits/extensions/cashu/migrations.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
async def m001_initial(db):
|
||||
"""
|
||||
Initial cashu table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE cashu.cashu (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tickershort TEXT DEFAULT 'sats',
|
||||
fraction BOOL,
|
||||
maxsats INT,
|
||||
coins INT,
|
||||
keyset_id TEXT NOT NULL,
|
||||
issued_sat INT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
"""
|
||||
Initial cashus table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE cashu.pegs (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
inout BOOL NOT NULL,
|
||||
amount INT
|
||||
);
|
||||
"""
|
||||
)
|
||||
147
lnbits/extensions/cashu/models.py
Normal file
147
lnbits/extensions/cashu/models.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
from sqlite3 import Row
|
||||
from typing import List, Union
|
||||
|
||||
from fastapi import Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Cashu(BaseModel):
|
||||
id: str = Query(None)
|
||||
name: str = Query(None)
|
||||
wallet: str = Query(None)
|
||||
tickershort: str = Query(None)
|
||||
fraction: bool = Query(None)
|
||||
maxsats: int = Query(0)
|
||||
coins: int = Query(0)
|
||||
keyset_id: str = Query(None)
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class Pegs(BaseModel):
|
||||
id: str
|
||||
wallet: str
|
||||
inout: str
|
||||
amount: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
class PayLnurlWData(BaseModel):
|
||||
lnurl: str
|
||||
|
||||
|
||||
class Promises(BaseModel):
|
||||
id: str
|
||||
amount: int
|
||||
B_b: str
|
||||
C_b: str
|
||||
cashu_id: str
|
||||
|
||||
|
||||
class Proof(BaseModel):
|
||||
amount: int
|
||||
secret: str
|
||||
C: str
|
||||
reserved: bool = False # whether this proof is reserved for sending
|
||||
send_id: str = "" # unique ID of send attempt
|
||||
time_created: str = ""
|
||||
time_reserved: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(
|
||||
amount=row[0],
|
||||
C=row[1],
|
||||
secret=row[2],
|
||||
reserved=row[3] or False,
|
||||
send_id=row[4] or "",
|
||||
time_created=row[5] or "",
|
||||
time_reserved=row[6] or "",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
assert "secret" in d, "no secret in proof"
|
||||
assert "amount" in d, "no amount in proof"
|
||||
return cls(
|
||||
amount=d.get("amount"),
|
||||
C=d.get("C"),
|
||||
secret=d.get("secret"),
|
||||
reserved=d.get("reserved") or False,
|
||||
send_id=d.get("send_id") or "",
|
||||
time_created=d.get("time_created") or "",
|
||||
time_reserved=d.get("time_reserved") or "",
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return dict(amount=self.amount, secret=self.secret, C=self.C)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.__getattribute__(key)
|
||||
|
||||
def __setitem__(self, key, val):
|
||||
self.__setattr__(key, val)
|
||||
|
||||
|
||||
class Proofs(BaseModel):
|
||||
"""TODO: Use this model"""
|
||||
|
||||
proofs: List[Proof]
|
||||
|
||||
|
||||
class Invoice(BaseModel):
|
||||
amount: int
|
||||
pr: str
|
||||
hash: str
|
||||
issued: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row):
|
||||
return cls(
|
||||
amount=int(row[0]),
|
||||
pr=str(row[1]),
|
||||
hash=str(row[2]),
|
||||
issued=bool(row[3]),
|
||||
)
|
||||
|
||||
|
||||
class BlindedMessage(BaseModel):
|
||||
amount: int
|
||||
B_: str
|
||||
|
||||
|
||||
class BlindedSignature(BaseModel):
|
||||
amount: int
|
||||
C_: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
return cls(
|
||||
amount=d["amount"],
|
||||
C_=d["C_"],
|
||||
)
|
||||
|
||||
|
||||
class MintPayloads(BaseModel):
|
||||
blinded_messages: List[BlindedMessage] = []
|
||||
|
||||
|
||||
class SplitPayload(BaseModel):
|
||||
proofs: List[Proof]
|
||||
amount: int
|
||||
output_data: MintPayloads
|
||||
|
||||
|
||||
class CheckPayload(BaseModel):
|
||||
proofs: List[Proof]
|
||||
|
||||
|
||||
class MeltPayload(BaseModel):
|
||||
proofs: List[Proof]
|
||||
amount: int
|
||||
invoice: str
|
||||
37
lnbits/extensions/cashu/static/js/base64.js
Normal file
37
lnbits/extensions/cashu/static/js/base64.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
function unescapeBase64Url(str) {
|
||||
return (str + '==='.slice((str.length + 3) % 4))
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
}
|
||||
|
||||
function escapeBase64Url(str) {
|
||||
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||
}
|
||||
|
||||
const uint8ToBase64 = (function (exports) {
|
||||
'use strict'
|
||||
|
||||
var fromCharCode = String.fromCharCode
|
||||
var encode = function encode(uint8array) {
|
||||
var output = []
|
||||
|
||||
for (var i = 0, length = uint8array.length; i < length; i++) {
|
||||
output.push(fromCharCode(uint8array[i]))
|
||||
}
|
||||
|
||||
return btoa(output.join(''))
|
||||
}
|
||||
|
||||
var asCharCode = function asCharCode(c) {
|
||||
return c.charCodeAt(0)
|
||||
}
|
||||
|
||||
var decode = function decode(chars) {
|
||||
return Uint8Array.from(atob(chars), asCharCode)
|
||||
}
|
||||
|
||||
exports.decode = decode
|
||||
exports.encode = encode
|
||||
|
||||
return exports
|
||||
})({})
|
||||
39
lnbits/extensions/cashu/static/js/dhke.js
Normal file
39
lnbits/extensions/cashu/static/js/dhke.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
async function hashToCurve(secretMessage) {
|
||||
console.log(
|
||||
'### secretMessage',
|
||||
nobleSecp256k1.utils.bytesToHex(secretMessage)
|
||||
)
|
||||
let point
|
||||
while (!point) {
|
||||
const hash = await nobleSecp256k1.utils.sha256(secretMessage)
|
||||
const hashHex = nobleSecp256k1.utils.bytesToHex(hash)
|
||||
const pointX = '02' + hashHex
|
||||
console.log('### pointX', pointX)
|
||||
try {
|
||||
point = nobleSecp256k1.Point.fromHex(pointX)
|
||||
console.log('### point', point.toHex())
|
||||
} catch (error) {
|
||||
secretMessage = await nobleSecp256k1.utils.sha256(secretMessage)
|
||||
}
|
||||
}
|
||||
return point
|
||||
}
|
||||
|
||||
async function step1Alice(secretMessage) {
|
||||
// todo: document & validate `secretMessage` format
|
||||
secretMessage = uint8ToBase64.encode(secretMessage)
|
||||
secretMessage = new TextEncoder().encode(secretMessage)
|
||||
const Y = await hashToCurve(secretMessage)
|
||||
const rpk = nobleSecp256k1.utils.randomPrivateKey()
|
||||
const r = bytesToNumber(rpk)
|
||||
const P = nobleSecp256k1.Point.fromPrivateKey(r)
|
||||
const B_ = Y.add(P)
|
||||
return {B_: B_.toHex(true), r: nobleSecp256k1.utils.bytesToHex(rpk)}
|
||||
}
|
||||
|
||||
function step3Alice(C_, r, A) {
|
||||
// const rInt = BigInt(r)
|
||||
const rInt = bytesToNumber(r)
|
||||
const C = C_.subtract(A.multiply(rInt))
|
||||
return C
|
||||
}
|
||||
1178
lnbits/extensions/cashu/static/js/noble-secp256k1.js
Normal file
1178
lnbits/extensions/cashu/static/js/noble-secp256k1.js
Normal file
File diff suppressed because it is too large
Load diff
23
lnbits/extensions/cashu/static/js/utils.js
Normal file
23
lnbits/extensions/cashu/static/js/utils.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
function splitAmount(value) {
|
||||
const chunks = []
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const mask = 1 << i
|
||||
if ((value & mask) !== 0) chunks.push(Math.pow(2, i))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function bytesToNumber(bytes) {
|
||||
return hexToNumber(nobleSecp256k1.utils.bytesToHex(bytes))
|
||||
}
|
||||
|
||||
function bigIntStringify(key, value) {
|
||||
return typeof value === 'bigint' ? value.toString() : value
|
||||
}
|
||||
|
||||
function hexToNumber(hex) {
|
||||
if (typeof hex !== 'string') {
|
||||
throw new TypeError('hexToNumber: expected string, got ' + typeof hex)
|
||||
}
|
||||
return BigInt(`0x${hex}`)
|
||||
}
|
||||
33
lnbits/extensions/cashu/tasks.py
Normal file
33
lnbits/extensions/cashu/tasks.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import asyncio
|
||||
import json
|
||||
|
||||
from cashu.core.migrations import migrate_databases
|
||||
from cashu.mint import migrations
|
||||
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from . import db, ledger
|
||||
from .crud import get_cashu
|
||||
|
||||
|
||||
async def startup_cashu_mint():
|
||||
await migrate_databases(db, migrations)
|
||||
await ledger.load_used_proofs()
|
||||
await ledger.init_keysets(autosave=False)
|
||||
pass
|
||||
|
||||
|
||||
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 payment.extra and not payment.extra.get("tag") == "cashu":
|
||||
return
|
||||
return
|
||||
80
lnbits/extensions/cashu/templates/cashu/_api_docs.html
Normal file
80
lnbits/extensions/cashu/templates/cashu/_api_docs.html
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/cashu"></q-btn>
|
||||
<!-- <q-expansion-item group="api" dense expand-separator label="List TPoS">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span> /cashu/api/v1/mints</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</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>[<cashu_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}cashu/api/v1/mints -H "X-Api-Key:
|
||||
<invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create a TPoS">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-green">POST</span> /cashu/api/v1/mints</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "currency": <string*ie USD*>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"currency": <string>, "id": <string>, "name":
|
||||
<string>, "wallet": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}cashu/api/v1/mints -d '{"name":
|
||||
<string>, "currency": <string>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: <admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a TPoS"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/cashu/api/v1/mints/<cashu_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}cashu/api/v1/mints/<cashu_id> -H "X-Api-Key:
|
||||
<admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item> -->
|
||||
</q-expansion-item>
|
||||
13
lnbits/extensions/cashu/templates/cashu/_cashu.html
Normal file
13
lnbits/extensions/cashu/templates/cashu/_cashu.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<q-expansion-item group="extras" icon="info" label="About">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<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
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
367
lnbits/extensions/cashu/templates/cashu/index.html
Normal file
367
lnbits/extensions/cashu/templates/cashu/index.html
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
{% 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>
|
||||
<b>Cashu mint and wallet</b>
|
||||
<p></p>
|
||||
<p>
|
||||
Here you can create multiple cashu mints that you can share. Each mint
|
||||
can service many users but all ecash tokens of a mint are only valid
|
||||
inside that mint and not across different mints. To exchange funds
|
||||
between mints, use Lightning payments.
|
||||
</p>
|
||||
<b>Important</b>
|
||||
<p></p>
|
||||
<p>
|
||||
If you are the operator of this LNbits instance, make sure to set
|
||||
CASHU_PRIVATE_KEY="randomkey" in your configuration file. Do not
|
||||
create mints before setting the key and do not change the key once
|
||||
set.
|
||||
</p>
|
||||
</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">Mints</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="cashus"
|
||||
row-key="id"
|
||||
:columns="cashusTable.columns"
|
||||
:pagination.sync="cashusTable.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="account_balance_wallet"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'wallet/?' + 'mint_id=' + props.row.id"
|
||||
target="_blank"
|
||||
><q-tooltip>Shareable wallet</q-tooltip></q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="account_balance"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'mint/' + props.row.id"
|
||||
target="_blank"
|
||||
><q-tooltip>Shareable mint page</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="deleteMint(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
<q-btn
|
||||
class="q-pt-l"
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="formDialog.show = true"
|
||||
>New Mint</q-btn
|
||||
>
|
||||
</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}} Cashu extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "cashu/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
{% include "cashu/_cashu.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="createMint" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
label="Mint Name"
|
||||
placeholder="Cashu Mint"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Cashu wallet *"
|
||||
></q-select>
|
||||
<!-- <q-toggle
|
||||
v-model="toggleAdvanced"
|
||||
label="Show advanced options"
|
||||
></q-toggle>
|
||||
<div v-show="toggleAdvanced">
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<q-checkbox
|
||||
v-model="formDialog.data.fraction"
|
||||
color="primary"
|
||||
label="sats/coins?"
|
||||
>
|
||||
<q-tooltip
|
||||
>Use with hedging extension to create a stablecoin!</q-tooltip
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<q-input
|
||||
v-if="!formDialog.data.fraction"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.trim="formDialog.data.cost"
|
||||
label="Sat coin cost (optional)"
|
||||
value="1"
|
||||
type="number"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="!formDialog.data.fraction"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.tickershort"
|
||||
label="Ticker shorthand"
|
||||
placeholder="sats"
|
||||
#
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
class="q-mt-md"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.trim="formDialog.data.maxsats"
|
||||
label="Maximum mint liquidity (optional)"
|
||||
placeholder="∞"
|
||||
></q-input>
|
||||
<q-input
|
||||
class="q-mt-md"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.trim="formDialog.data.coins"
|
||||
label="Coins that 'exist' in mint (optional)"
|
||||
placeholder="∞"
|
||||
></q-input>
|
||||
</div> -->
|
||||
<div class="row q-mt-md">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.wallet == null || formDialog.data.name == null"
|
||||
type="submit"
|
||||
>Create Mint
|
||||
</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 mapMint = 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.cashu = ['/cashu/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
cashus: [],
|
||||
hostname: location.protocol + '//' + location.host + '/cashu/mint/',
|
||||
toggleAdvanced: false,
|
||||
cashusTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'Mint ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
// {
|
||||
// name: 'tickershort',
|
||||
// align: 'left',
|
||||
// label: 'Ticker',
|
||||
// field: 'tickershort'
|
||||
// },
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Mint wallet',
|
||||
field: 'wallet'
|
||||
}
|
||||
// {
|
||||
// name: 'fraction',
|
||||
// align: 'left',
|
||||
// label: 'Using fraction',
|
||||
// field: 'fraction'
|
||||
// },
|
||||
// {
|
||||
// name: 'maxsats',
|
||||
// align: 'left',
|
||||
// label: 'Max Sats',
|
||||
// field: 'maxsats'
|
||||
// },
|
||||
// {
|
||||
// name: 'coins',
|
||||
// align: 'left',
|
||||
// label: 'No. of coins',
|
||||
// field: 'coins'
|
||||
// }
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {fraction: false}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {}
|
||||
},
|
||||
getMints: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/cashu/api/v1/mints?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cashus = response.data.map(function (obj) {
|
||||
return mapMint(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
createMint: function () {
|
||||
if (this.formDialog.data.maxliquid == null) {
|
||||
this.formDialog.data.maxliquid = 0
|
||||
}
|
||||
var data = {
|
||||
name: this.formDialog.data.name,
|
||||
tickershort: this.formDialog.data.tickershort,
|
||||
maxliquid: this.formDialog.data.maxliquid
|
||||
}
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/cashu/api/v1/mints',
|
||||
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cashus.push(mapMint(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteMint: function (cashuId) {
|
||||
var self = this
|
||||
var cashu = _.findWhere(this.cashus, {id: cashuId})
|
||||
console.log(cashu)
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
"Are you sure you want to delete this Mint? This mint's users will not be able to redeem their tokens!"
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/cashu/api/v1/mints/' + cashuId,
|
||||
_.findWhere(self.g.user.wallets, {id: cashu.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cashus = _.reject(self.cashus, function (obj) {
|
||||
return obj.id == cashuId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.cashusTable.columns, this.cashus)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getMints()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
76
lnbits/extensions/cashu/templates/cashu/mint.html
Normal file
76
lnbits/extensions/cashu/templates/cashu/mint.html
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg q-mb-xl">
|
||||
<q-card-section class="q-pa-none">
|
||||
<center>
|
||||
<q-icon
|
||||
name="account_balance"
|
||||
class="text-grey"
|
||||
style="font-size: 10rem"
|
||||
></q-icon>
|
||||
<h4 class="q-mt-none q-mb-md">{{ mint_name }}</h4>
|
||||
<a
|
||||
class="q-my-xl text-white"
|
||||
style="font-size: 1.5rem"
|
||||
href="../wallet?mint_id={{ mint_id }}"
|
||||
>Open wallet</a
|
||||
>
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg q-mb-xl">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h5 class="q-my-md">Read the following carefully!</h5>
|
||||
<p>
|
||||
This is a
|
||||
<a href="https://cashu.space/" style="color: white" target="”_blank”"
|
||||
>Cashu</a
|
||||
>
|
||||
mint. Cashu is an ecash system for Bitcoin.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Open this page in your native browser</strong><br />
|
||||
Before you continue to the wallet, make sure to open this page in your
|
||||
device's native browser application (Safari for iOS, Chrome for
|
||||
Android). Do not use Cashu in an embedded browser that opens when you
|
||||
click a link in a messenger.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Add wallet to home screen</strong><br />
|
||||
You can add Cashu to your home screen as a progressive web app (PWA).
|
||||
After opening the wallet in your browser (click the link above), on
|
||||
Android (Chrome), click the menu at the upper right. On iOS (Safari),
|
||||
click the share button. Now press the Add to Home screen button.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Backup your wallet</strong><br />
|
||||
Ecash is a bearer asset. That means losing access to your wallet will
|
||||
make you lose your funds. The wallet stores ecash tokens on your
|
||||
device's database. If you lose the link or delete your your data
|
||||
without backing up, you will lose your tokens. Press the Backup button
|
||||
in the wallet to download a copy of your tokens.
|
||||
</p>
|
||||
<p>
|
||||
<strong>This service is in BETA</strong> <br />
|
||||
We hold no responsibility for people losing access to funds. Use at
|
||||
your own risk!
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
</div>
|
||||
2337
lnbits/extensions/cashu/templates/cashu/wallet.html
Normal file
2337
lnbits/extensions/cashu/templates/cashu/wallet.html
Normal file
File diff suppressed because it is too large
Load diff
224
lnbits/extensions/cashu/views.py
Normal file
224
lnbits/extensions/cashu/views.py
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.params import Depends
|
||||
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 cashu_ext, cashu_renderer
|
||||
from .crud import get_cashu
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@cashu_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists), # type: ignore
|
||||
):
|
||||
return cashu_renderer().TemplateResponse(
|
||||
"cashu/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
||||
|
||||
@cashu_ext.get("/wallet")
|
||||
async def wallet(request: Request, mint_id: str):
|
||||
return cashu_renderer().TemplateResponse(
|
||||
"cashu/wallet.html",
|
||||
{
|
||||
"request": request,
|
||||
"web_manifest": f"/cashu/manifest/{mint_id}.webmanifest",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@cashu_ext.get("/mint/{mintID}")
|
||||
async def cashu(request: Request, mintID):
|
||||
cashu = await get_cashu(mintID)
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
|
||||
)
|
||||
return cashu_renderer().TemplateResponse(
|
||||
"cashu/mint.html",
|
||||
{"request": request, "mint_name": cashu.name, "mint_id": mintID},
|
||||
)
|
||||
|
||||
|
||||
@cashu_ext.get("/manifest/{cashu_id}.webmanifest")
|
||||
async def manifest(cashu_id: str):
|
||||
cashu = await get_cashu(cashu_id)
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
|
||||
)
|
||||
|
||||
return {
|
||||
"short_name": "Cashu",
|
||||
"name": "Cashu" + " - " + cashu.name,
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-512-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-96-96.png",
|
||||
"type": "image/png",
|
||||
"sizes": "96x96",
|
||||
},
|
||||
],
|
||||
"id": "/cashu/wallet?mint_id=" + cashu_id,
|
||||
"start_url": "/cashu/wallet?mint_id=" + cashu_id,
|
||||
"background_color": "#1F2234",
|
||||
"description": "Cashu ecash wallet",
|
||||
"display": "standalone",
|
||||
"scope": "/cashu/",
|
||||
"theme_color": "#1F2234",
|
||||
"protocol_handlers": [
|
||||
{"protocol": "cashu", "url": "&recv_token=%s"},
|
||||
{"protocol": "lightning", "url": "&lightning=%s"},
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Cashu" + " - " + cashu.name,
|
||||
"short_name": "Cashu",
|
||||
"description": "Cashu" + " - " + cashu.name,
|
||||
"url": "/cashu/wallet?mint_id=" + cashu_id,
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-512-512.png",
|
||||
"sizes": "512x512",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-192-192.png",
|
||||
"sizes": "192x192",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-144-144.png",
|
||||
"sizes": "144x144",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-96-96.png",
|
||||
"sizes": "96x96",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-72-72.png",
|
||||
"sizes": "72x72",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-48-48.png",
|
||||
"sizes": "48x48",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/16.png",
|
||||
"sizes": "16x16",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/20.png",
|
||||
"sizes": "20x20",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/29.png",
|
||||
"sizes": "29x29",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/32.png",
|
||||
"sizes": "32x32",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/40.png",
|
||||
"sizes": "40x40",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/50.png",
|
||||
"sizes": "50x50",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/57.png",
|
||||
"sizes": "57x57",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/58.png",
|
||||
"sizes": "58x58",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/60.png",
|
||||
"sizes": "60x60",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/64.png",
|
||||
"sizes": "64x64",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/72.png",
|
||||
"sizes": "72x72",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/76.png",
|
||||
"sizes": "76x76",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/80.png",
|
||||
"sizes": "80x80",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/87.png",
|
||||
"sizes": "87x87",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/100.png",
|
||||
"sizes": "100x100",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/114.png",
|
||||
"sizes": "114x114",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/120.png",
|
||||
"sizes": "120x120",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/128.png",
|
||||
"sizes": "128x128",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/144.png",
|
||||
"sizes": "144x144",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/152.png",
|
||||
"sizes": "152x152",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/167.png",
|
||||
"sizes": "167x167",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/180.png",
|
||||
"sizes": "180x180",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/192.png",
|
||||
"sizes": "192x192",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/256.png",
|
||||
"sizes": "256x256",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/512.png",
|
||||
"sizes": "512x512",
|
||||
},
|
||||
{
|
||||
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/1024.png",
|
||||
"sizes": "1024x1024",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
382
lnbits/extensions/cashu/views_api.py
Normal file
382
lnbits/extensions/cashu/views_api.py
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
import json
|
||||
import math
|
||||
from http import HTTPStatus
|
||||
from typing import Dict, List, Union
|
||||
|
||||
import httpx
|
||||
|
||||
# -------- cashu imports
|
||||
from cashu.core.base import (
|
||||
BlindedSignature,
|
||||
CheckFeesRequest,
|
||||
CheckFeesResponse,
|
||||
CheckRequest,
|
||||
GetMeltResponse,
|
||||
GetMintResponse,
|
||||
Invoice,
|
||||
MeltRequest,
|
||||
MintRequest,
|
||||
PostSplitResponse,
|
||||
Proof,
|
||||
SplitRequest,
|
||||
)
|
||||
from fastapi import Query
|
||||
from fastapi.params import Depends
|
||||
from lnurl import decode as decode_lnurl
|
||||
from loguru import logger
|
||||
from secp256k1 import PublicKey
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.crud import check_internal, get_user
|
||||
from lnbits.core.services import (
|
||||
check_transaction_status,
|
||||
create_invoice,
|
||||
fee_reserve,
|
||||
pay_invoice,
|
||||
)
|
||||
from lnbits.core.views.api import api_payment
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
from lnbits.wallets.base import PaymentStatus
|
||||
|
||||
from . import cashu_ext, ledger
|
||||
from .crud import create_cashu, delete_cashu, get_cashu, get_cashus
|
||||
from .models import Cashu
|
||||
|
||||
# --------- extension imports
|
||||
|
||||
|
||||
LIGHTNING = True
|
||||
|
||||
########################################
|
||||
############### LNBITS MINTS ###########
|
||||
########################################
|
||||
|
||||
|
||||
@cashu_ext.get("/api/v1/mints", status_code=HTTPStatus.OK)
|
||||
async def api_cashus(
|
||||
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||
):
|
||||
"""
|
||||
Get all mints of this wallet.
|
||||
"""
|
||||
wallet_ids = [wallet.wallet.id]
|
||||
if all_wallets:
|
||||
user = await get_user(wallet.wallet.user)
|
||||
if user:
|
||||
wallet_ids = user.wallet_ids
|
||||
|
||||
return [cashu.dict() for cashu in await get_cashus(wallet_ids)]
|
||||
|
||||
|
||||
@cashu_ext.post("/api/v1/mints", status_code=HTTPStatus.CREATED)
|
||||
async def api_cashu_create(
|
||||
data: Cashu,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
|
||||
):
|
||||
"""
|
||||
Create a new mint for this wallet.
|
||||
"""
|
||||
cashu_id = urlsafe_short_hash()
|
||||
# generate a new keyset in cashu
|
||||
keyset = await ledger.load_keyset(cashu_id)
|
||||
|
||||
cashu = await create_cashu(
|
||||
cashu_id=cashu_id, keyset_id=keyset.id, wallet_id=wallet.wallet.id, data=data
|
||||
)
|
||||
logger.debug(cashu)
|
||||
return cashu.dict()
|
||||
|
||||
|
||||
@cashu_ext.delete("/api/v1/mints/{cashu_id}")
|
||||
async def api_cashu_delete(
|
||||
cashu_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) # type: ignore
|
||||
):
|
||||
"""
|
||||
Delete an existing cashu mint.
|
||||
"""
|
||||
cashu = await get_cashu(cashu_id)
|
||||
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Cashu mint does not exist."
|
||||
)
|
||||
|
||||
if cashu.wallet != wallet.wallet.id:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="Not your Cashu mint."
|
||||
)
|
||||
|
||||
await delete_cashu(cashu_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
|
||||
|
||||
#######################################
|
||||
########### CASHU ENDPOINTS ###########
|
||||
#######################################
|
||||
|
||||
|
||||
@cashu_ext.get("/api/v1/{cashu_id}/keys", status_code=HTTPStatus.OK)
|
||||
async def keys(cashu_id: str = Query(None)) -> dict[int, str]:
|
||||
"""Get the public keys of the mint"""
|
||||
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
|
||||
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
|
||||
return ledger.get_keyset(keyset_id=cashu.keyset_id)
|
||||
|
||||
|
||||
@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"""
|
||||
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
|
||||
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
|
||||
return {"keysets": [cashu.keyset_id]}
|
||||
|
||||
|
||||
@cashu_ext.get("/api/v1/{cashu_id}/mint")
|
||||
async def request_mint(cashu_id: str = Query(None), amount: int = 0) -> GetMintResponse:
|
||||
"""
|
||||
Request minting of new tokens. The mint responds with a Lightning invoice.
|
||||
This endpoint can be used for a Lightning invoice UX flow.
|
||||
|
||||
Call `POST /mint` after paying the invoice.
|
||||
"""
|
||||
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
|
||||
|
||||
if not cashu:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
|
||||
# create an invoice that the wallet needs to pay
|
||||
try:
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=cashu.wallet,
|
||||
amount=amount,
|
||||
memo=f"{cashu.name}",
|
||||
extra={"tag": "cashu"},
|
||||
)
|
||||
invoice = Invoice(
|
||||
amount=amount, pr=payment_request, hash=payment_hash, issued=False
|
||||
)
|
||||
# await store_lightning_invoice(cashu_id, invoice)
|
||||
await ledger.crud.store_lightning_invoice(invoice=invoice, db=ledger.db)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
print(f"Lightning invoice: {payment_request}")
|
||||
resp = GetMintResponse(pr=payment_request, hash=payment_hash)
|
||||
# return {"pr": payment_request, "hash": payment_hash}
|
||||
return resp
|
||||
|
||||
|
||||
@cashu_ext.post("/api/v1/{cashu_id}/mint")
|
||||
async def mint_coins(
|
||||
data: MintRequest,
|
||||
cashu_id: str = Query(None),
|
||||
payment_hash: str = Query(None),
|
||||
) -> List[BlindedSignature]:
|
||||
"""
|
||||
Requests the minting of tokens belonging to a paid payment request.
|
||||
Call this endpoint after `GET /mint`.
|
||||
"""
|
||||
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
|
||||
if cashu is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
|
||||
if LIGHTNING:
|
||||
invoice: Invoice = await ledger.crud.get_lightning_invoice(
|
||||
db=ledger.db, hash=payment_hash
|
||||
)
|
||||
if invoice is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND,
|
||||
detail="Mint does not know this invoice.",
|
||||
)
|
||||
if invoice.issued == True:
|
||||
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 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."
|
||||
)
|
||||
await ledger.crud.update_lightning_invoice(
|
||||
db=ledger.db, hash=payment_hash, issued=True
|
||||
)
|
||||
|
||||
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")
|
||||
async def melt_coins(
|
||||
payload: MeltRequest, cashu_id: str = Query(None)
|
||||
) -> GetMeltResponse:
|
||||
"""Invalidates proofs and pays a Lightning invoice."""
|
||||
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
|
||||
if cashu is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
proofs = payload.proofs
|
||||
invoice = payload.invoice
|
||||
|
||||
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
|
||||
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
|
||||
# TOKENS
|
||||
assert all([p.id == cashu.keyset_id for p in proofs]), HTTPException(
|
||||
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
|
||||
detail="Error: Tokens are from another mint.",
|
||||
)
|
||||
|
||||
assert all([ledger._verify_proof(p) for p in proofs]), HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="Could not verify proofs.",
|
||||
)
|
||||
|
||||
total_provided = sum([p["amount"] for p in proofs])
|
||||
invoice_obj = bolt11.decode(invoice)
|
||||
amount = math.ceil(invoice_obj.amount_msat / 1000)
|
||||
|
||||
internal_checking_id = await check_internal(invoice_obj.payment_hash)
|
||||
|
||||
if not internal_checking_id:
|
||||
fees_msat = fee_reserve(invoice_obj.amount_msat)
|
||||
else:
|
||||
fees_msat = 0
|
||||
assert total_provided >= amount + fees_msat / 1000, Exception(
|
||||
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
|
||||
)
|
||||
|
||||
await pay_invoice(
|
||||
wallet_id=cashu.wallet,
|
||||
payment_request=invoice,
|
||||
description=f"pay cashu invoice",
|
||||
extra={"tag": "cashu", "cahsu_name": cashu.name},
|
||||
)
|
||||
|
||||
status: PaymentStatus = await check_transaction_status(
|
||||
cashu.wallet, invoice_obj.payment_hash
|
||||
)
|
||||
if status.paid == True:
|
||||
await ledger._invalidate_proofs(proofs)
|
||||
return GetMeltResponse(paid=status.paid, preimage=status.preimage)
|
||||
|
||||
|
||||
@cashu_ext.post("/api/v1/{cashu_id}/check")
|
||||
async def check_spendable(
|
||||
payload: CheckRequest, cashu_id: str = Query(None)
|
||||
) -> Dict[int, bool]:
|
||||
"""Check whether a secret has been spent already or not."""
|
||||
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
|
||||
if cashu is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
return await ledger.check_spendable(payload.proofs)
|
||||
|
||||
|
||||
@cashu_ext.post("/api/v1/{cashu_id}/checkfees")
|
||||
async def check_fees(
|
||||
payload: CheckFeesRequest, cashu_id: str = Query(None)
|
||||
) -> CheckFeesResponse:
|
||||
"""
|
||||
Responds with the fees necessary to pay a Lightning invoice.
|
||||
Used by wallets for figuring out the fees they need to supply.
|
||||
This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu).
|
||||
"""
|
||||
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
|
||||
if cashu is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
invoice_obj = bolt11.decode(payload.pr)
|
||||
internal_checking_id = await check_internal(invoice_obj.payment_hash)
|
||||
|
||||
if not internal_checking_id:
|
||||
fees_msat = fee_reserve(invoice_obj.amount_msat)
|
||||
else:
|
||||
fees_msat = 0
|
||||
return CheckFeesResponse(fee=fees_msat / 1000)
|
||||
|
||||
|
||||
@cashu_ext.post("/api/v1/{cashu_id}/split")
|
||||
async def split(
|
||||
payload: SplitRequest, cashu_id: str = Query(None)
|
||||
) -> PostSplitResponse:
|
||||
"""
|
||||
Requetst a set of tokens with amount "total" to be split into two
|
||||
newly minted sets with amount "split" and "total-split".
|
||||
"""
|
||||
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
|
||||
if cashu is None:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
|
||||
)
|
||||
proofs = payload.proofs
|
||||
|
||||
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
|
||||
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
|
||||
# TOKENS
|
||||
if not all([p.id == cashu.keyset_id for p in proofs]):
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
|
||||
detail="Error: Tokens are from another mint.",
|
||||
)
|
||||
|
||||
amount = payload.amount
|
||||
outputs = payload.outputs.blinded_messages
|
||||
assert outputs, Exception("no outputs provided.")
|
||||
split_return = None
|
||||
try:
|
||||
keyset = ledger.keysets.keysets[cashu.keyset_id]
|
||||
split_return = await ledger.split(proofs, amount, outputs, keyset)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail=str(exc),
|
||||
)
|
||||
if not split_return:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
detail="there was an error with the split",
|
||||
)
|
||||
frst_promises, scnd_promises = split_return
|
||||
resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises)
|
||||
return resp
|
||||
|
|
@ -10,7 +10,7 @@ from .models import Copilots, CreateCopilotData
|
|||
|
||||
async def create_copilot(
|
||||
data: CreateCopilotData, inkey: Optional[str] = ""
|
||||
) -> Copilots:
|
||||
) -> Optional[Copilots]:
|
||||
copilot_id = urlsafe_short_hash()
|
||||
await db.execute(
|
||||
"""
|
||||
|
|
@ -67,19 +67,19 @@ async def create_copilot(
|
|||
|
||||
|
||||
async def update_copilot(
|
||||
data: CreateCopilotData, copilot_id: Optional[str] = ""
|
||||
data: CreateCopilotData, copilot_id: str
|
||||
) -> Optional[Copilots]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in data])
|
||||
items = [f"{field[1]}" for field in data]
|
||||
items.append(copilot_id)
|
||||
await db.execute(f"UPDATE copilot.newer_copilots SET {q} WHERE id = ?", (items))
|
||||
await db.execute(f"UPDATE copilot.newer_copilots SET {q} WHERE id = ?", (items,))
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
|
||||
)
|
||||
return Copilots(**row) if row else None
|
||||
|
||||
|
||||
async def get_copilot(copilot_id: str) -> Copilots:
|
||||
async def get_copilot(copilot_id: str) -> Optional[Copilots]:
|
||||
row = await db.fetchone(
|
||||
"SELECT * FROM copilot.newer_copilots WHERE id = ?", (copilot_id,)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ from starlette.exceptions import HTTPException
|
|||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_copilot
|
||||
from .views import updater
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
|
|
@ -26,7 +26,7 @@ async def wait_for_paid_invoices():
|
|||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
webhook = None
|
||||
data = None
|
||||
if payment.extra.get("tag") != "copilot":
|
||||
if not payment.extra or payment.extra.get("tag") != "copilot":
|
||||
# not an copilot invoice
|
||||
return
|
||||
|
||||
|
|
@ -65,18 +65,20 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
if payment.extra.get("comment"):
|
||||
await updater(copilot.id, data, payment.extra.get("comment"))
|
||||
await websocketUpdater(
|
||||
copilot.id, str(data) + "-" + str(payment.extra.get("comment"))
|
||||
)
|
||||
|
||||
await updater(copilot.id, data, "none")
|
||||
await websocketUpdater(copilot.id, str(data) + "-none")
|
||||
|
||||
|
||||
async def mark_webhook_sent(payment: Payment, status: int) -> None:
|
||||
payment.extra["wh_status"] = status
|
||||
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(json.dumps(payment.extra), payment.payment_hash),
|
||||
)
|
||||
if payment.extra:
|
||||
payment.extra["wh_status"] = status
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments SET extra = ?
|
||||
WHERE hash = ?
|
||||
""",
|
||||
(json.dumps(payment.extra), payment.payment_hash),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -238,7 +238,7 @@
|
|||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/copilot/ws/' +
|
||||
'/api/v1/ws/' +
|
||||
self.copilot.id
|
||||
} else {
|
||||
localUrl =
|
||||
|
|
@ -246,7 +246,7 @@
|
|||
document.domain +
|
||||
':' +
|
||||
location.port +
|
||||
'/copilot/ws/' +
|
||||
'/api/v1/ws/' +
|
||||
self.copilot.id
|
||||
}
|
||||
this.connection = new WebSocket(localUrl)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ templates = Jinja2Templates(directory="templates")
|
|||
|
||||
|
||||
@copilot_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists) # type: ignore
|
||||
):
|
||||
return copilot_renderer().TemplateResponse(
|
||||
"copilot/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
|
@ -33,48 +35,3 @@ async def panel(request: Request):
|
|||
return copilot_renderer().TemplateResponse(
|
||||
"copilot/panel.html", {"request": request}
|
||||
)
|
||||
|
||||
|
||||
##################WEBSOCKET ROUTES########################
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
self.active_connections: List[WebSocket] = []
|
||||
|
||||
async def connect(self, websocket: WebSocket, copilot_id: str):
|
||||
await websocket.accept()
|
||||
websocket.id = copilot_id
|
||||
self.active_connections.append(websocket)
|
||||
|
||||
def disconnect(self, websocket: WebSocket):
|
||||
self.active_connections.remove(websocket)
|
||||
|
||||
async def send_personal_message(self, message: str, copilot_id: str):
|
||||
for connection in self.active_connections:
|
||||
if connection.id == copilot_id:
|
||||
await connection.send_text(message)
|
||||
|
||||
async def broadcast(self, message: str):
|
||||
for connection in self.active_connections:
|
||||
await connection.send_text(message)
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
@copilot_ext.websocket("/ws/{copilot_id}", name="copilot.websocket_by_id")
|
||||
async def websocket_endpoint(websocket: WebSocket, copilot_id: str):
|
||||
await manager.connect(websocket, copilot_id)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
|
||||
|
||||
async def updater(copilot_id, data, comment):
|
||||
copilot = await get_copilot(copilot_id)
|
||||
if not copilot:
|
||||
return
|
||||
await manager.send_personal_message(f"{data + '-' + comment}", copilot_id)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from fastapi.param_functions import Query
|
|||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.services import websocketUpdater
|
||||
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
|
||||
|
||||
from . import copilot_ext
|
||||
|
|
@ -16,14 +17,13 @@ from .crud import (
|
|||
update_copilot,
|
||||
)
|
||||
from .models import CreateCopilotData
|
||||
from .views import updater
|
||||
|
||||
#######################COPILOT##########################
|
||||
|
||||
|
||||
@copilot_ext.get("/api/v1/copilot")
|
||||
async def api_copilots_retrieve(
|
||||
req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
req: Request, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||
):
|
||||
wallet_user = wallet.wallet.user
|
||||
copilots = [copilot.dict() for copilot in await get_copilots(wallet_user)]
|
||||
|
|
@ -37,7 +37,7 @@ async def api_copilots_retrieve(
|
|||
async def api_copilot_retrieve(
|
||||
req: Request,
|
||||
copilot_id: str = Query(None),
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
|
||||
):
|
||||
copilot = await get_copilot(copilot_id)
|
||||
if not copilot:
|
||||
|
|
@ -54,7 +54,7 @@ async def api_copilot_retrieve(
|
|||
async def api_copilot_create_or_update(
|
||||
data: CreateCopilotData,
|
||||
copilot_id: str = Query(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
):
|
||||
data.user = wallet.wallet.user
|
||||
data.wallet = wallet.wallet.id
|
||||
|
|
@ -67,7 +67,8 @@ async def api_copilot_create_or_update(
|
|||
|
||||
@copilot_ext.delete("/api/v1/copilot/{copilot_id}")
|
||||
async def api_copilot_delete(
|
||||
copilot_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
copilot_id: str = Query(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
):
|
||||
copilot = await get_copilot(copilot_id)
|
||||
|
||||
|
|
@ -91,7 +92,7 @@ async def api_copilot_ws_relay(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
|
||||
)
|
||||
try:
|
||||
await updater(copilot_id, data, comment)
|
||||
await websocketUpdater(copilot_id, str(data) + "-" + str(comment))
|
||||
except:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -98,21 +98,21 @@ async def get_discordbot_wallet(wallet_id: str) -> Optional[Wallets]:
|
|||
return Wallets(**row) if row else None
|
||||
|
||||
|
||||
async def get_discordbot_wallets(admin_id: str) -> Optional[Wallets]:
|
||||
async def get_discordbot_wallets(admin_id: str) -> List[Wallets]:
|
||||
rows = await db.fetchall(
|
||||
"SELECT * FROM discordbot.wallets WHERE admin = ?", (admin_id,)
|
||||
)
|
||||
return [Wallets(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_discordbot_users_wallets(user_id: str) -> Optional[Wallets]:
|
||||
async def get_discordbot_users_wallets(user_id: str) -> List[Wallets]:
|
||||
rows = await db.fetchall(
|
||||
"""SELECT * FROM discordbot.wallets WHERE "user" = ?""", (user_id,)
|
||||
)
|
||||
return [Wallets(**row) for row in rows]
|
||||
|
||||
|
||||
async def get_discordbot_wallet_transactions(wallet_id: str) -> Optional[Payment]:
|
||||
async def get_discordbot_wallet_transactions(wallet_id: str) -> List[Payment]:
|
||||
return await get_payments(
|
||||
wallet_id=wallet_id, complete=True, pending=False, outgoing=True, incoming=True
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ from . import discordbot_ext, discordbot_renderer
|
|||
|
||||
|
||||
@discordbot_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists) # type: ignore
|
||||
):
|
||||
return discordbot_renderer().TemplateResponse(
|
||||
"discordbot/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -27,32 +27,37 @@ from .models import CreateUserData, CreateUserWallet
|
|||
|
||||
|
||||
@discordbot_ext.get("/api/v1/users", status_code=HTTPStatus.OK)
|
||||
async def api_discordbot_users(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
async def api_discordbot_users(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
|
||||
):
|
||||
user_id = wallet.wallet.user
|
||||
return [user.dict() for user in await get_discordbot_users(user_id)]
|
||||
|
||||
|
||||
@discordbot_ext.get("/api/v1/users/{user_id}", status_code=HTTPStatus.OK)
|
||||
async def api_discordbot_user(user_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
async def api_discordbot_user(
|
||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||
):
|
||||
user = await get_discordbot_user(user_id)
|
||||
return user.dict()
|
||||
if user:
|
||||
return user.dict()
|
||||
|
||||
|
||||
@discordbot_ext.post("/api/v1/users", status_code=HTTPStatus.CREATED)
|
||||
async def api_discordbot_users_create(
|
||||
data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
data: CreateUserData, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||
):
|
||||
user = await create_discordbot_user(data)
|
||||
full = user.dict()
|
||||
full["wallets"] = [
|
||||
wallet.dict() for wallet in await get_discordbot_users_wallets(user.id)
|
||||
]
|
||||
wallets = await get_discordbot_users_wallets(user.id)
|
||||
if wallets:
|
||||
full["wallets"] = [wallet for wallet in wallets]
|
||||
return full
|
||||
|
||||
|
||||
@discordbot_ext.delete("/api/v1/users/{user_id}")
|
||||
async def api_discordbot_users_delete(
|
||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||
):
|
||||
user = await get_discordbot_user(user_id)
|
||||
if not user:
|
||||
|
|
@ -60,7 +65,7 @@ async def api_discordbot_users_delete(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
)
|
||||
await delete_discordbot_user(user_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
# Activate Extension
|
||||
|
|
@ -75,7 +80,7 @@ async def api_discordbot_activate_extension(
|
|||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="User does not exist."
|
||||
)
|
||||
update_user_extension(user_id=userid, extension=extension, active=active)
|
||||
await update_user_extension(user_id=userid, extension=extension, active=active)
|
||||
return {"extension": "updated"}
|
||||
|
||||
|
||||
|
|
@ -84,7 +89,7 @@ async def api_discordbot_activate_extension(
|
|||
|
||||
@discordbot_ext.post("/api/v1/wallets")
|
||||
async def api_discordbot_wallets_create(
|
||||
data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
data: CreateUserWallet, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||
):
|
||||
user = await create_discordbot_wallet(
|
||||
user_id=data.user_id, wallet_name=data.wallet_name, admin_id=data.admin_id
|
||||
|
|
@ -93,28 +98,30 @@ async def api_discordbot_wallets_create(
|
|||
|
||||
|
||||
@discordbot_ext.get("/api/v1/wallets")
|
||||
async def api_discordbot_wallets(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
async def api_discordbot_wallets(
|
||||
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
|
||||
):
|
||||
admin_id = wallet.wallet.user
|
||||
return [wallet.dict() for wallet in await get_discordbot_wallets(admin_id)]
|
||||
return await get_discordbot_wallets(admin_id)
|
||||
|
||||
|
||||
@discordbot_ext.get("/api/v1/transactions/{wallet_id}")
|
||||
async def api_discordbot_wallet_transactions(
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||
):
|
||||
return await get_discordbot_wallet_transactions(wallet_id)
|
||||
|
||||
|
||||
@discordbot_ext.get("/api/v1/wallets/{user_id}")
|
||||
async def api_discordbot_users_wallets(
|
||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
user_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||
):
|
||||
return [s_wallet.dict() for s_wallet in await get_discordbot_users_wallets(user_id)]
|
||||
return await get_discordbot_users_wallets(user_id)
|
||||
|
||||
|
||||
@discordbot_ext.delete("/api/v1/wallets/{wallet_id}")
|
||||
async def api_discordbot_wallets_delete(
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
|
||||
):
|
||||
get_wallet = await get_discordbot_wallet(wallet_id)
|
||||
if not get_wallet:
|
||||
|
|
@ -122,4 +129,4 @@ async def api_discordbot_wallets_delete(
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Wallet does not exist."
|
||||
)
|
||||
await delete_discordbot_wallet(wallet_id, get_wallet.user)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_events")
|
||||
|
||||
|
|
@ -13,5 +16,11 @@ def events_renderer():
|
|||
return template_renderer(["lnbits/extensions/events/templates"])
|
||||
|
||||
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def events_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
|
|
|||
39
lnbits/extensions/events/tasks.py
Normal file
39
lnbits/extensions/events/tasks.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import asyncio
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import pay_invoice
|
||||
from lnbits.extensions.events.models import CreateTicket
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .views_api import api_ticket_send_ticket
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, get_current_extension_name())
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
# (avoid loops)
|
||||
if (
|
||||
"events" == payment.extra.get("tag")
|
||||
and payment.extra.get("name")
|
||||
and payment.extra.get("email")
|
||||
):
|
||||
CreateTicket.name = str(payment.extra.get("name"))
|
||||
CreateTicket.email = str(payment.extra.get("email"))
|
||||
await api_ticket_send_ticket(payment.memo, payment.payment_hash, CreateTicket)
|
||||
return
|
||||
|
|
@ -135,7 +135,14 @@
|
|||
var self = this
|
||||
axios
|
||||
|
||||
.get('/events/api/v1/tickets/' + '{{ event_id }}')
|
||||
.get(
|
||||
'/events/api/v1/tickets/' +
|
||||
'{{ event_id }}' +
|
||||
'/' +
|
||||
self.formDialog.data.name +
|
||||
'/' +
|
||||
self.formDialog.data.email
|
||||
)
|
||||
.then(function (response) {
|
||||
self.paymentReq = response.data.payment_request
|
||||
self.paymentCheck = response.data.payment_hash
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@
|
|||
dense
|
||||
v-model.number="formDialog.data.price_per_ticket"
|
||||
type="number"
|
||||
label="Price per ticket "
|
||||
label="Sats per ticket "
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from http import HTTPStatus
|
|||
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
|
|
@ -78,7 +79,7 @@ async def api_form_delete(event_id, wallet: WalletTypeInfo = Depends(get_key_typ
|
|||
|
||||
await delete_event(event_id)
|
||||
await delete_event_tickets(event_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
#########Tickets##########
|
||||
|
|
@ -96,8 +97,8 @@ async def api_tickets(
|
|||
return [ticket.dict() for ticket in await get_tickets(wallet_ids)]
|
||||
|
||||
|
||||
@events_ext.get("/api/v1/tickets/{event_id}")
|
||||
async def api_ticket_make_ticket(event_id):
|
||||
@events_ext.get("/api/v1/tickets/{event_id}/{name}/{email}")
|
||||
async def api_ticket_make_ticket(event_id, name, email):
|
||||
event = await get_event(event_id)
|
||||
if not event:
|
||||
raise HTTPException(
|
||||
|
|
@ -108,11 +109,10 @@ async def api_ticket_make_ticket(event_id):
|
|||
wallet_id=event.wallet,
|
||||
amount=event.price_per_ticket,
|
||||
memo=f"{event_id}",
|
||||
extra={"tag": "events"},
|
||||
extra={"tag": "events", "name": name, "email": email},
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
|
||||
|
||||
return {"payment_hash": payment_hash, "payment_request": payment_request}
|
||||
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_
|
|||
)
|
||||
|
||||
await delete_ticket(ticket_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
# Event Tickets
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ templates = Jinja2Templates(directory="templates")
|
|||
|
||||
|
||||
@example_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
async def index(
|
||||
request: Request,
|
||||
user: User = Depends(check_user_exists), # type: ignore
|
||||
):
|
||||
return example_renderer().TemplateResponse(
|
||||
"example/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ async def m001_initial_invoices(db):
|
|||
id TEXT PRIMARY KEY,
|
||||
invoice_id TEXT NOT NULL,
|
||||
|
||||
amount INT NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
|
||||
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now},
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@
|
|||
dense
|
||||
v-model.trim="formDialog.data.company_name"
|
||||
label="Company Name"
|
||||
placeholder="LNBits Labs"
|
||||
placeholder="LNbits Labs"
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@
|
|||

|
||||
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
|
||||

|
||||
- back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
|
||||
- choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
|
||||
- back on LNbits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
|
||||
- choose on which device the LNbits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
|
||||
- and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import List, Optional
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
|
|
@ -6,11 +6,9 @@ from . import db
|
|||
from .models import CreateJukeboxPayment, CreateJukeLinkData, Jukebox, JukeboxPayment
|
||||
|
||||
|
||||
async def create_jukebox(
|
||||
data: CreateJukeLinkData, inkey: Optional[str] = ""
|
||||
) -> Jukebox:
|
||||
async def create_jukebox(data: CreateJukeLinkData) -> Jukebox:
|
||||
juke_id = urlsafe_short_hash()
|
||||
result = await db.execute(
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO jukebox.jukebox (id, "user", title, wallet, sp_user, sp_secret, sp_access_token, sp_refresh_token, sp_device, sp_playlists, price, profit)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
|
|
@ -36,13 +34,13 @@ async def create_jukebox(
|
|||
|
||||
|
||||
async def update_jukebox(
|
||||
data: CreateJukeLinkData, juke_id: Optional[str] = ""
|
||||
data: Union[CreateJukeLinkData, Jukebox], juke_id: str = ""
|
||||
) -> Optional[Jukebox]:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in data])
|
||||
items = [f"{field[1]}" for field in data]
|
||||
items.append(juke_id)
|
||||
q = q.replace("user", '"user"', 1) # hack to make user be "user"!
|
||||
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items))
|
||||
await db.execute(f"UPDATE jukebox.jukebox SET {q} WHERE id = ?", (items,))
|
||||
row = await db.fetchone("SELECT * FROM jukebox.jukebox WHERE id = ?", (juke_id,))
|
||||
return Jukebox(**row) if row else None
|
||||
|
||||
|
|
@ -72,7 +70,7 @@ async def delete_jukebox(juke_id: str):
|
|||
"""
|
||||
DELETE FROM jukebox.jukebox WHERE id = ?
|
||||
""",
|
||||
(juke_id),
|
||||
(juke_id,),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -80,7 +78,7 @@ async def delete_jukebox(juke_id: str):
|
|||
|
||||
|
||||
async def create_jukebox_payment(data: CreateJukeboxPayment) -> JukeboxPayment:
|
||||
result = await db.execute(
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO jukebox.jukebox_payment (payment_hash, juke_id, song_id, paid)
|
||||
VALUES (?, ?, ?, ?)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,3 @@
|
|||
from sqlite3 import Row
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
from fastapi.param_functions import Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic.main import BaseModel
|
||||
|
|
@ -20,19 +17,19 @@ class CreateJukeLinkData(BaseModel):
|
|||
|
||||
|
||||
class Jukebox(BaseModel):
|
||||
id: Optional[str]
|
||||
user: Optional[str]
|
||||
title: Optional[str]
|
||||
wallet: Optional[str]
|
||||
inkey: Optional[str]
|
||||
sp_user: Optional[str]
|
||||
sp_secret: Optional[str]
|
||||
sp_access_token: Optional[str]
|
||||
sp_refresh_token: Optional[str]
|
||||
sp_device: Optional[str]
|
||||
sp_playlists: Optional[str]
|
||||
price: Optional[int]
|
||||
profit: Optional[int]
|
||||
id: str
|
||||
user: str
|
||||
title: str
|
||||
wallet: str
|
||||
inkey: str
|
||||
sp_user: str
|
||||
sp_secret: str
|
||||
sp_access_token: str
|
||||
sp_refresh_token: str
|
||||
sp_device: str
|
||||
sp_playlists: str
|
||||
price: int
|
||||
profit: int
|
||||
|
||||
|
||||
class JukeboxPayment(BaseModel):
|
||||
|
|
|
|||
|
|
@ -17,7 +17,8 @@ async def wait_for_paid_invoices():
|
|||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
if payment.extra.get("tag") != "jukebox":
|
||||
# not a jukebox invoice
|
||||
return
|
||||
await update_jukebox_payment(payment.payment_hash, paid=True)
|
||||
if payment.extra:
|
||||
if payment.extra.get("tag") != "jukebox":
|
||||
# not a jukebox invoice
|
||||
return
|
||||
await update_jukebox_payment(payment.payment_hash, paid=True)
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ templates = Jinja2Templates(directory="templates")
|
|||
|
||||
|
||||
@jukebox_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists) # type: ignore
|
||||
):
|
||||
return jukebox_renderer().TemplateResponse(
|
||||
"jukebox/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
|
@ -31,6 +33,7 @@ async def connect_to_jukebox(request: Request, juke_id):
|
|||
status_code=HTTPStatus.NOT_FOUND, detail="Jukebox does not exist."
|
||||
)
|
||||
devices = await api_get_jukebox_device_check(juke_id)
|
||||
deviceConnected = False
|
||||
for device in devices["devices"]:
|
||||
if device["id"] == jukebox.sp_device.split("-")[1]:
|
||||
deviceConnected = True
|
||||
|
|
@ -48,5 +51,5 @@ async def connect_to_jukebox(request: Request, juke_id):
|
|||
else:
|
||||
return jukebox_renderer().TemplateResponse(
|
||||
"jukebox/error.html",
|
||||
{"request": request, "jukebox": jukebox.jukebox(req=request)},
|
||||
{"request": request, "jukebox": jukebox.dict()},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import json
|
|||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from starlette.exceptions import HTTPException
|
||||
|
|
@ -29,9 +28,7 @@ from .models import CreateJukeboxPayment, CreateJukeLinkData
|
|||
|
||||
@jukebox_ext.get("/api/v1/jukebox")
|
||||
async def api_get_jukeboxs(
|
||||
req: Request,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
all_wallets: bool = Query(False),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key), # type: ignore
|
||||
):
|
||||
wallet_user = wallet.wallet.user
|
||||
|
||||
|
|
@ -53,54 +50,52 @@ async def api_check_credentials_callbac(
|
|||
access_token: str = Query(None),
|
||||
refresh_token: str = Query(None),
|
||||
):
|
||||
sp_code = ""
|
||||
sp_access_token = ""
|
||||
sp_refresh_token = ""
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
if not jukebox:
|
||||
raise HTTPException(detail="No Jukebox", status_code=HTTPStatus.FORBIDDEN)
|
||||
if code:
|
||||
jukebox.sp_access_token = code
|
||||
jukebox = await update_jukebox(jukebox, juke_id=juke_id)
|
||||
await update_jukebox(jukebox, juke_id=juke_id)
|
||||
if access_token:
|
||||
jukebox.sp_access_token = access_token
|
||||
jukebox.sp_refresh_token = refresh_token
|
||||
jukebox = await update_jukebox(jukebox, juke_id=juke_id)
|
||||
await update_jukebox(jukebox, juke_id=juke_id)
|
||||
return "<h1>Success!</h1><h2>You can close this window</h2>"
|
||||
|
||||
|
||||
@jukebox_ext.get("/api/v1/jukebox/{juke_id}")
|
||||
async def api_check_credentials_check(
|
||||
juke_id: str = Query(None), wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
):
|
||||
@jukebox_ext.get("/api/v1/jukebox/{juke_id}", dependencies=[Depends(require_admin_key)])
|
||||
async def api_check_credentials_check(juke_id: str = Query(None)):
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
return jukebox
|
||||
|
||||
|
||||
@jukebox_ext.post("/api/v1/jukebox", status_code=HTTPStatus.CREATED)
|
||||
@jukebox_ext.post(
|
||||
"/api/v1/jukebox",
|
||||
status_code=HTTPStatus.CREATED,
|
||||
dependencies=[Depends(require_admin_key)],
|
||||
)
|
||||
@jukebox_ext.put("/api/v1/jukebox/{juke_id}", status_code=HTTPStatus.OK)
|
||||
async def api_create_update_jukebox(
|
||||
data: CreateJukeLinkData,
|
||||
juke_id: str = Query(None),
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
data: CreateJukeLinkData, juke_id: str = Query(None)
|
||||
):
|
||||
if juke_id:
|
||||
jukebox = await update_jukebox(data, juke_id=juke_id)
|
||||
else:
|
||||
jukebox = await create_jukebox(data, inkey=wallet.wallet.inkey)
|
||||
jukebox = await create_jukebox(data)
|
||||
return jukebox
|
||||
|
||||
|
||||
@jukebox_ext.delete("/api/v1/jukebox/{juke_id}")
|
||||
@jukebox_ext.delete(
|
||||
"/api/v1/jukebox/{juke_id}", dependencies=[Depends(require_admin_key)]
|
||||
)
|
||||
async def api_delete_item(
|
||||
juke_id=None, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||
juke_id: str = Query(None),
|
||||
):
|
||||
await delete_jukebox(juke_id)
|
||||
try:
|
||||
return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
|
||||
except:
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
|
||||
# try:
|
||||
# return [{**jukebox} for jukebox in await get_jukeboxs(wallet.wallet.user)]
|
||||
# except:
|
||||
# raise HTTPException(status_code=HTTPStatus.NO_CONTENT, detail="No Jukebox")
|
||||
|
||||
|
||||
################JUKEBOX ENDPOINTS##################
|
||||
|
|
@ -114,9 +109,8 @@ async def api_get_jukebox_song(
|
|||
sp_playlist: str = Query(None),
|
||||
retry: bool = Query(False),
|
||||
):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
if not jukebox:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
||||
tracks = []
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
|
@ -152,14 +146,13 @@ async def api_get_jukebox_song(
|
|||
}
|
||||
)
|
||||
except:
|
||||
something = None
|
||||
pass
|
||||
return [track for track in tracks]
|
||||
|
||||
|
||||
async def api_get_token(juke_id=None):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
async def api_get_token(juke_id):
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
if not jukebox:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
|
|
@ -187,7 +180,7 @@ async def api_get_token(juke_id=None):
|
|||
jukebox.sp_access_token = r.json()["access_token"]
|
||||
await update_jukebox(jukebox, juke_id=juke_id)
|
||||
except:
|
||||
something = None
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
|
|
@ -198,9 +191,8 @@ async def api_get_token(juke_id=None):
|
|||
async def api_get_jukebox_device_check(
|
||||
juke_id: str = Query(None), retry: bool = Query(False)
|
||||
):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
if not jukebox:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No Jukeboxes")
|
||||
async with httpx.AsyncClient() as client:
|
||||
rDevice = await client.get(
|
||||
|
|
@ -221,7 +213,7 @@ async def api_get_jukebox_device_check(
|
|||
status_code=HTTPStatus.FORBIDDEN, detail="Failed to get auth"
|
||||
)
|
||||
else:
|
||||
return api_get_jukebox_device_check(juke_id, retry=True)
|
||||
return await api_get_jukebox_device_check(juke_id, retry=True)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="No device connected"
|
||||
|
|
@ -233,10 +225,8 @@ async def api_get_jukebox_device_check(
|
|||
|
||||
@jukebox_ext.get("/api/v1/jukebox/jb/invoice/{juke_id}/{song_id}")
|
||||
async def api_get_jukebox_invoice(juke_id, song_id):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
|
||||
except:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
if not jukebox:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||
try:
|
||||
|
||||
|
|
@ -266,8 +256,7 @@ async def api_get_jukebox_invoice(juke_id, song_id):
|
|||
invoice=invoice[1], payment_hash=payment_hash, juke_id=juke_id, song_id=song_id
|
||||
)
|
||||
jukebox_payment = await create_jukebox_payment(data)
|
||||
|
||||
return data
|
||||
return jukebox_payment
|
||||
|
||||
|
||||
@jukebox_ext.get("/api/v1/jukebox/jb/checkinvoice/{pay_hash}/{juke_id}")
|
||||
|
|
@ -296,13 +285,12 @@ async def api_get_jukebox_invoice_paid(
|
|||
pay_hash: str = Query(None),
|
||||
retry: bool = Query(False),
|
||||
):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
if not jukebox:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||
await api_get_jukebox_invoice_check(pay_hash, juke_id)
|
||||
jukebox_payment = await get_jukebox_payment(pay_hash)
|
||||
if jukebox_payment.paid:
|
||||
if jukebox_payment and jukebox_payment.paid:
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
"https://api.spotify.com/v1/me/player/currently-playing?market=ES",
|
||||
|
|
@ -407,9 +395,8 @@ async def api_get_jukebox_invoice_paid(
|
|||
async def api_get_jukebox_currently(
|
||||
retry: bool = Query(False), juke_id: str = Query(None)
|
||||
):
|
||||
try:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
except:
|
||||
jukebox = await get_jukebox(juke_id)
|
||||
if not jukebox:
|
||||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="No jukebox")
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Help DJ's and music producers conduct music livestreams
|
||||
|
||||
LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
|
||||
LNbits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
|
||||
|
||||
When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional).
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ The revenue will be sent to a wallet created specifically for that producer, wit
|
|||

|
||||
3. For every different producer added, when adding tracks, a wallet is generated for them\
|
||||

|
||||
4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
|
||||
4. On the bottom of the LNbits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
|
||||
5. After all tracks and producers are added, you can start "playing" songs\
|
||||

|
||||
6. You'll see the current track playing and a green icon indicating active track also\
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import json
|
|||
from loguru import logger
|
||||
|
||||
from lnbits.core import db as core_db
|
||||
from lnbits.core.crud import create_payment
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.helpers import get_current_extension_name, urlsafe_short_hash
|
||||
from lnbits.tasks import internal_invoice_listener, register_invoice_listener
|
||||
from lnbits.core.services import create_invoice, pay_invoice
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_livestream_by_track, get_producer, get_track
|
||||
|
||||
|
|
@ -44,44 +44,20 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
# now we make a special kind of internal transfer
|
||||
amount = int(payment.amount * (100 - ls.fee_pct) / 100)
|
||||
|
||||
# mark the original payment with two extra keys, "shared_with" and "received"
|
||||
# (this prevents us from doing this process again and it's informative)
|
||||
# and reduce it by the amount we're going to send to the producer
|
||||
await core_db.execute(
|
||||
"""
|
||||
UPDATE apipayments
|
||||
SET extra = ?, amount = ?
|
||||
WHERE hash = ?
|
||||
AND checking_id NOT LIKE 'internal_%'
|
||||
""",
|
||||
(
|
||||
json.dumps(
|
||||
dict(
|
||||
**payment.extra,
|
||||
shared_with=[producer.name, producer.id],
|
||||
received=payment.amount,
|
||||
)
|
||||
),
|
||||
payment.amount - amount,
|
||||
payment.payment_hash,
|
||||
),
|
||||
)
|
||||
|
||||
# perform an internal transfer using the same payment_hash to the producer wallet
|
||||
internal_checking_id = f"internal_{urlsafe_short_hash()}"
|
||||
await create_payment(
|
||||
wallet_id=producer.wallet,
|
||||
checking_id=internal_checking_id,
|
||||
payment_request="",
|
||||
payment_hash=payment.payment_hash,
|
||||
amount=amount,
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=tpos.tip_wallet,
|
||||
amount=amount, # sats
|
||||
internal=True,
|
||||
memo=f"Revenue from '{track.name}'.",
|
||||
pending=False,
|
||||
)
|
||||
logger.debug(f"livestream: producer invoice created: {payment_hash}")
|
||||
|
||||
# manually send this for now
|
||||
# await internal_invoice_paid.send(internal_checking_id)
|
||||
await internal_invoice_listener.put(internal_checking_id)
|
||||
checking_id = await pay_invoice(
|
||||
payment_request=payment_request,
|
||||
wallet_id=payment.wallet_id,
|
||||
extra={"tag": "livestream"},
|
||||
)
|
||||
logger.debug(f"livestream: producer invoice paid: {checking_id}")
|
||||
|
||||
# so the flow is the following:
|
||||
# - we receive, say, 1000 satoshis
|
||||
|
|
|
|||
|
|
@ -60,14 +60,14 @@ async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
|
|||
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await update_current_track(ls.id, id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@livestream_ext.put("/api/v1/livestream/fee/{fee_pct}")
|
||||
async def api_update_fee(fee_pct, g: WalletTypeInfo = Depends(get_key_type)):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await update_livestream_fee(ls.id, int(fee_pct))
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@livestream_ext.post("/api/v1/livestream/tracks")
|
||||
|
|
@ -93,8 +93,8 @@ async def api_add_track(
|
|||
return
|
||||
|
||||
|
||||
@livestream_ext.route("/api/v1/livestream/tracks/{track_id}")
|
||||
@livestream_ext.delete("/api/v1/livestream/tracks/{track_id}")
|
||||
async def api_delete_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
|
||||
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
|
||||
await delete_track_from_livestream(ls.id, track_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ async def api_domain_delete(domain_id, g: WalletTypeInfo = Depends(get_key_type)
|
|||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your domain")
|
||||
|
||||
await delete_domain(domain_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
# ADDRESSES
|
||||
|
|
@ -253,4 +253,4 @@ async def api_address_delete(address_id, g: WalletTypeInfo = Depends(get_key_typ
|
|||
)
|
||||
|
||||
await delete_address(address_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
|
|
|||
|
|
@ -3,4 +3,4 @@
|
|||
|
||||
Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/.
|
||||
|
||||
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club.
|
||||
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNbits joins the same club.
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ async def api_form_delete(form_id, wallet: WalletTypeInfo = Depends(get_key_type
|
|||
|
||||
await delete_form(form_id)
|
||||
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
#########tickets##########
|
||||
|
|
@ -160,4 +160,4 @@ async def api_ticket_delete(ticket_id, wallet: WalletTypeInfo = Depends(get_key_
|
|||
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your ticket.")
|
||||
|
||||
await delete_ticket(ticket_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import asyncio
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import template_renderer
|
||||
from lnbits.tasks import catch_everything_and_restart
|
||||
|
||||
db = Database("ext_lnurldevice")
|
||||
|
||||
|
|
@ -13,5 +16,11 @@ def lnurldevice_renderer():
|
|||
|
||||
|
||||
from .lnurl import * # noqa
|
||||
from .tasks import wait_for_paid_invoices
|
||||
from .views import * # noqa
|
||||
from .views_api import * # noqa
|
||||
|
||||
|
||||
def lnurldevice_start():
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))
|
||||
|
|
|
|||
|
|
@ -22,9 +22,23 @@ async def create_lnurldevice(
|
|||
wallet,
|
||||
currency,
|
||||
device,
|
||||
profit
|
||||
profit,
|
||||
amount,
|
||||
pin,
|
||||
profit1,
|
||||
amount1,
|
||||
pin1,
|
||||
profit2,
|
||||
amount2,
|
||||
pin2,
|
||||
profit3,
|
||||
amount3,
|
||||
pin3,
|
||||
profit4,
|
||||
amount4,
|
||||
pin4
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
lnurldevice_id,
|
||||
|
|
@ -34,6 +48,20 @@ async def create_lnurldevice(
|
|||
data.currency,
|
||||
data.device,
|
||||
data.profit,
|
||||
data.amount,
|
||||
data.pin,
|
||||
data.profit1,
|
||||
data.amount1,
|
||||
data.pin1,
|
||||
data.profit2,
|
||||
data.amount2,
|
||||
data.pin2,
|
||||
data.profit3,
|
||||
data.amount3,
|
||||
data.pin3,
|
||||
data.profit4,
|
||||
data.amount4,
|
||||
data.pin4,
|
||||
),
|
||||
)
|
||||
return await get_lnurldevice(lnurldevice_id)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing import Optional
|
|||
from embit import bech32, compact
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from loguru import logger
|
||||
from starlette.exceptions import HTTPException
|
||||
|
||||
from lnbits.core.services import create_invoice
|
||||
|
|
@ -91,6 +92,9 @@ async def lnurl_v1_params(
|
|||
device_id: str = Query(None),
|
||||
p: str = Query(None),
|
||||
atm: str = Query(None),
|
||||
gpio: str = Query(None),
|
||||
profit: str = Query(None),
|
||||
amount: str = Query(None),
|
||||
):
|
||||
device = await get_lnurldevice(device_id)
|
||||
if not device:
|
||||
|
|
@ -101,8 +105,41 @@ async def lnurl_v1_params(
|
|||
paymentcheck = await get_lnurlpayload(p)
|
||||
if device.device == "atm":
|
||||
if paymentcheck:
|
||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||
if paymentcheck.payhash != "payment_hash":
|
||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||
if device.device == "switch":
|
||||
price_msat = (
|
||||
await fiat_amount_as_satoshis(float(profit), device.currency)
|
||||
if device.currency != "sat"
|
||||
else amount_in_cent
|
||||
) * 1000
|
||||
|
||||
# Check they're not trying to trick the switch!
|
||||
check = False
|
||||
for switch in device.switches(request):
|
||||
if switch[0] == gpio and switch[1] == profit and switch[2] == amount:
|
||||
check = True
|
||||
if not check:
|
||||
return {"status": "ERROR", "reason": f"Switch params wrong"}
|
||||
|
||||
lnurldevicepayment = await create_lnurldevicepayment(
|
||||
deviceid=device.id,
|
||||
payload=amount,
|
||||
sats=price_msat,
|
||||
pin=gpio,
|
||||
payhash="bla",
|
||||
)
|
||||
if not lnurldevicepayment:
|
||||
return {"status": "ERROR", "reason": "Could not create payment."}
|
||||
return {
|
||||
"tag": "payRequest",
|
||||
"callback": request.url_for(
|
||||
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
|
||||
),
|
||||
"minSendable": price_msat,
|
||||
"maxSendable": price_msat,
|
||||
"metadata": device.lnurlpay_metadata,
|
||||
}
|
||||
if len(p) % 4 > 0:
|
||||
p += "=" * (4 - (len(p) % 4))
|
||||
|
||||
|
|
@ -140,7 +177,7 @@ async def lnurl_v1_params(
|
|||
"callback": request.url_for(
|
||||
"lnurldevice.lnurl_callback", paymentid=lnurldevicepayment.id
|
||||
),
|
||||
"k1": lnurldevicepayment.id,
|
||||
"k1": p,
|
||||
"minWithdrawable": price_msat * 1000,
|
||||
"maxWithdrawable": price_msat * 1000,
|
||||
"defaultDescription": device.title,
|
||||
|
|
@ -163,7 +200,7 @@ async def lnurl_v1_params(
|
|||
),
|
||||
"minSendable": price_msat * 1000,
|
||||
"maxSendable": price_msat * 1000,
|
||||
"metadata": await device.lnurlpay_metadata(),
|
||||
"metadata": device.lnurlpay_metadata,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -184,28 +221,53 @@ async def lnurl_callback(
|
|||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="lnurldevice not found."
|
||||
)
|
||||
if pr:
|
||||
if lnurldevicepayment.id != k1:
|
||||
return {"status": "ERROR", "reason": "Bad K1"}
|
||||
if lnurldevicepayment.payhash != "payment_hash":
|
||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||
if device.device == "atm":
|
||||
if not pr:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.FORBIDDEN, detail="No payment request"
|
||||
)
|
||||
else:
|
||||
if lnurldevicepayment.payload != k1:
|
||||
return {"status": "ERROR", "reason": "Bad K1"}
|
||||
if lnurldevicepayment.payhash != "payment_hash":
|
||||
return {"status": "ERROR", "reason": f"Payment already claimed"}
|
||||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
|
||||
)
|
||||
|
||||
await pay_invoice(
|
||||
await pay_invoice(
|
||||
wallet_id=device.wallet,
|
||||
payment_request=pr,
|
||||
max_sat=lnurldevicepayment.sats / 1000,
|
||||
extra={"tag": "withdraw"},
|
||||
)
|
||||
return {"status": "OK"}
|
||||
if device.device == "switch":
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=device.wallet,
|
||||
payment_request=pr,
|
||||
max_sat=lnurldevicepayment.sats / 1000,
|
||||
extra={"tag": "withdraw"},
|
||||
amount=int(lnurldevicepayment.sats / 1000),
|
||||
memo=device.id + " PIN " + str(lnurldevicepayment.pin),
|
||||
unhashed_description=device.lnurlpay_metadata.encode("utf-8"),
|
||||
extra={
|
||||
"tag": "Switch",
|
||||
"pin": str(lnurldevicepayment.pin),
|
||||
"amount": str(lnurldevicepayment.payload),
|
||||
"id": paymentid,
|
||||
},
|
||||
)
|
||||
return {"status": "OK"}
|
||||
|
||||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
lnurldevicepayment_id=paymentid, payhash=payment_hash
|
||||
)
|
||||
return {
|
||||
"pr": payment_request,
|
||||
"routes": [],
|
||||
}
|
||||
|
||||
payment_hash, payment_request = await create_invoice(
|
||||
wallet_id=device.wallet,
|
||||
amount=lnurldevicepayment.sats / 1000,
|
||||
amount=int(lnurldevicepayment.sats / 1000),
|
||||
memo=device.title,
|
||||
unhashed_description=(await device.lnurlpay_metadata()).encode("utf-8"),
|
||||
unhashed_description=device.lnurlpay_metadata.encode("utf-8"),
|
||||
extra={"tag": "PoS"},
|
||||
)
|
||||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
|
|
@ -221,5 +283,3 @@ async def lnurl_callback(
|
|||
},
|
||||
"routes": [],
|
||||
}
|
||||
|
||||
return resp.dict()
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ async def m001_initial(db):
|
|||
payhash TEXT,
|
||||
payload TEXT NOT NULL,
|
||||
pin INT,
|
||||
sats INT,
|
||||
sats {db.big_int},
|
||||
timestamp TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
|
||||
);
|
||||
"""
|
||||
|
|
@ -79,3 +79,61 @@ async def m002_redux(db):
|
|||
)
|
||||
except:
|
||||
return
|
||||
|
||||
|
||||
async def m003_redux(db):
|
||||
"""
|
||||
Add 'meta' for storing various metadata about the wallet
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount INT DEFAULT 0;"
|
||||
)
|
||||
|
||||
|
||||
async def m004_redux(db):
|
||||
"""
|
||||
Add 'meta' for storing various metadata about the wallet
|
||||
"""
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin INT DEFAULT 0"
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit1 FLOAT DEFAULT 0"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount1 INT DEFAULT 0"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin1 INT DEFAULT 0"
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit2 FLOAT DEFAULT 0"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount2 INT DEFAULT 0"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin2 INT DEFAULT 0"
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit3 FLOAT DEFAULT 0"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount3 INT DEFAULT 0"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin3 INT DEFAULT 0"
|
||||
)
|
||||
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN profit4 FLOAT DEFAULT 0"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN amount4 INT DEFAULT 0"
|
||||
)
|
||||
await db.execute(
|
||||
"ALTER TABLE lnurldevice.lnurldevices ADD COLUMN pin4 INT DEFAULT 0"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import json
|
||||
from sqlite3 import Row
|
||||
from typing import Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import Request
|
||||
from lnurl import Lnurl
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.models import LnurlPaySuccessAction, UrlAction # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from pydantic.main import BaseModel
|
||||
|
||||
|
|
@ -17,6 +18,20 @@ class createLnurldevice(BaseModel):
|
|||
currency: str
|
||||
device: str
|
||||
profit: float
|
||||
amount: int
|
||||
pin: int = 0
|
||||
profit1: float = 0
|
||||
amount1: int = 0
|
||||
pin1: int = 0
|
||||
profit2: float = 0
|
||||
amount2: int = 0
|
||||
pin2: int = 0
|
||||
profit3: float = 0
|
||||
amount3: int = 0
|
||||
pin3: int = 0
|
||||
profit4: float = 0
|
||||
amount4: int = 0
|
||||
pin4: int = 0
|
||||
|
||||
|
||||
class lnurldevices(BaseModel):
|
||||
|
|
@ -27,20 +42,123 @@ class lnurldevices(BaseModel):
|
|||
currency: str
|
||||
device: str
|
||||
profit: float
|
||||
amount: int
|
||||
pin: int
|
||||
profit1: float
|
||||
amount1: int
|
||||
pin1: int
|
||||
profit2: float
|
||||
amount2: int
|
||||
pin2: int
|
||||
profit3: float
|
||||
amount3: int
|
||||
pin3: int
|
||||
profit4: float
|
||||
amount4: int
|
||||
pin4: int
|
||||
timestamp: str
|
||||
|
||||
def from_row(cls, row: Row) -> "lnurldevices":
|
||||
return cls(**dict(row))
|
||||
|
||||
def lnurl(self, req: Request) -> Lnurl:
|
||||
url = req.url_for(
|
||||
"lnurldevice.lnurl_response", device_id=self.id, _external=True
|
||||
)
|
||||
return lnurl_encode(url)
|
||||
|
||||
async def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
@property
|
||||
def lnurlpay_metadata(self) -> LnurlPayMetadata:
|
||||
return LnurlPayMetadata(json.dumps([["text/plain", self.title]]))
|
||||
|
||||
def switches(self, req: Request) -> List:
|
||||
switches = []
|
||||
if self.profit > 0:
|
||||
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
|
||||
switches.append(
|
||||
[
|
||||
str(self.pin),
|
||||
str(self.profit),
|
||||
str(self.amount),
|
||||
lnurl_encode(
|
||||
url
|
||||
+ "?gpio="
|
||||
+ str(self.pin)
|
||||
+ "&profit="
|
||||
+ str(self.profit)
|
||||
+ "&amount="
|
||||
+ str(self.amount)
|
||||
),
|
||||
]
|
||||
)
|
||||
if self.profit1 > 0:
|
||||
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
|
||||
switches.append(
|
||||
[
|
||||
str(self.pin1),
|
||||
str(self.profit1),
|
||||
str(self.amount1),
|
||||
lnurl_encode(
|
||||
url
|
||||
+ "?gpio="
|
||||
+ str(self.pin1)
|
||||
+ "&profit="
|
||||
+ str(self.profit1)
|
||||
+ "&amount="
|
||||
+ str(self.amount1)
|
||||
),
|
||||
]
|
||||
)
|
||||
if self.profit2 > 0:
|
||||
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
|
||||
switches.append(
|
||||
[
|
||||
str(self.pin2),
|
||||
str(self.profit2),
|
||||
str(self.amount2),
|
||||
lnurl_encode(
|
||||
url
|
||||
+ "?gpio="
|
||||
+ str(self.pin2)
|
||||
+ "&profit="
|
||||
+ str(self.profit2)
|
||||
+ "&amount="
|
||||
+ str(self.amount2)
|
||||
),
|
||||
]
|
||||
)
|
||||
if self.profit3 > 0:
|
||||
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
|
||||
switches.append(
|
||||
[
|
||||
str(self.pin3),
|
||||
str(self.profit3),
|
||||
str(self.amount3),
|
||||
lnurl_encode(
|
||||
url
|
||||
+ "?gpio="
|
||||
+ str(self.pin3)
|
||||
+ "&profit="
|
||||
+ str(self.profit3)
|
||||
+ "&amount="
|
||||
+ str(self.amount3)
|
||||
),
|
||||
]
|
||||
)
|
||||
if self.profit4 > 0:
|
||||
url = req.url_for("lnurldevice.lnurl_v1_params", device_id=self.id)
|
||||
switches.append(
|
||||
[
|
||||
str(self.pin4),
|
||||
str(self.profit4),
|
||||
str(self.amount4),
|
||||
lnurl_encode(
|
||||
url
|
||||
+ "?gpio="
|
||||
+ str(self.pin4)
|
||||
+ "&profit="
|
||||
+ str(self.profit4)
|
||||
+ "&amount="
|
||||
+ str(self.amount4)
|
||||
),
|
||||
]
|
||||
)
|
||||
return switches
|
||||
|
||||
|
||||
class lnurldevicepayment(BaseModel):
|
||||
id: str
|
||||
|
|
|
|||
42
lnbits/extensions/lnurldevice/tasks.py
Normal file
42
lnbits/extensions/lnurldevice/tasks.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import asyncio
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
|
||||
from lnbits import bolt11
|
||||
from lnbits.core.models import Payment
|
||||
from lnbits.core.services import pay_invoice, websocketUpdater
|
||||
from lnbits.helpers import get_current_extension_name
|
||||
from lnbits.tasks import register_invoice_listener
|
||||
|
||||
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
|
||||
|
||||
|
||||
async def wait_for_paid_invoices():
|
||||
invoice_queue = asyncio.Queue()
|
||||
register_invoice_listener(invoice_queue, get_current_extension_name())
|
||||
|
||||
while True:
|
||||
payment = await invoice_queue.get()
|
||||
await on_invoice_paid(payment)
|
||||
|
||||
|
||||
async def on_invoice_paid(payment: Payment) -> None:
|
||||
# (avoid loops)
|
||||
if "Switch" == payment.extra.get("tag"):
|
||||
lnurldevicepayment = await get_lnurldevicepayment(payment.extra.get("id"))
|
||||
if not lnurldevicepayment:
|
||||
return
|
||||
if lnurldevicepayment.payhash == "used":
|
||||
return
|
||||
lnurldevicepayment = await update_lnurldevicepayment(
|
||||
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
|
||||
)
|
||||
return await websocketUpdater(
|
||||
lnurldevicepayment.deviceid,
|
||||
str(lnurldevicepayment.pin) + "-" + str(lnurldevicepayment.payload),
|
||||
)
|
||||
return
|
||||
|
|
@ -1,13 +1,24 @@
|
|||
<q-card>
|
||||
<q-card-section>
|
||||
<p>
|
||||
Register LNURLDevice devices to receive payments in your LNbits wallet.<br />
|
||||
Build your own here
|
||||
<a href="https://github.com/arcbtc/bitcoinpos"
|
||||
>https://github.com/arcbtc/bitcoinpos</a
|
||||
For LNURL based Points of Sale, ATMs, and relay devices<br />
|
||||
Use with: <br />
|
||||
LNPoS
|
||||
<a href="https://lnbits.github.io/lnpos">
|
||||
https://lnbits.github.io/lnpos</a
|
||||
><br />
|
||||
bitcoinSwitch
|
||||
<a href="https://github.com/lnbits/bitcoinSwitch">
|
||||
https://github.com/lnbits/bitcoinSwitch</a
|
||||
><br />
|
||||
FOSSA
|
||||
<a 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></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
|
||||
>
|
||||
</p>
|
||||
</q-card-section>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
<q-tr :props="props">
|
||||
<q-th style="width: 5%"></q-th>
|
||||
<q-th style="width: 5%"></q-th>
|
||||
<q-th style="width: 5%"></q-th>
|
||||
|
||||
<q-th
|
||||
v-for="col in props.cols"
|
||||
|
|
@ -91,6 +92,22 @@
|
|||
<q-tooltip> LNURLDevice Settings </q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
<q-td>
|
||||
<q-btn
|
||||
v-if="props.row.device == 'switch'"
|
||||
:disable="protocol == 'http:'"
|
||||
flat
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="visibility"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
@click="openQrCodeDialog(props.row.id)"
|
||||
><q-tooltip v-if="protocol == 'http:'">
|
||||
LNURLs only work over HTTPS </q-tooltip
|
||||
><q-tooltip v-else> view LNURLS </q-tooltip></q-btn
|
||||
>
|
||||
</q-td>
|
||||
<q-td
|
||||
v-for="col in props.cols"
|
||||
:key="col.name"
|
||||
|
|
@ -132,20 +149,33 @@
|
|||
class="q-pa-lg q-pt-xl lnbits__dialog-card"
|
||||
>
|
||||
<div class="text-h6">LNURLDevice device string</div>
|
||||
<q-btn
|
||||
dense
|
||||
outline
|
||||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
@click="copyText(location + '/lnurldevice/api/v1/lnurl/' + settingsDialog.data.id + ',' +
|
||||
<center>
|
||||
<q-btn
|
||||
v-if="settingsDialog.data.device == 'switch'"
|
||||
dense
|
||||
outline
|
||||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
@click="copyText(wslocation + '/api/v1/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
|
||||
>{% raw %}{{wslocation}}/api/v1/ws/{{settingsDialog.data.id}}{% endraw
|
||||
%}<q-tooltip> Click to copy URL </q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn
|
||||
v-else
|
||||
dense
|
||||
outline
|
||||
unelevated
|
||||
color="primary"
|
||||
size="md"
|
||||
@click="copyText(location + '/lnurldevice/api/v1/lnurl/' + settingsDialog.data.id + ',' +
|
||||
settingsDialog.data.key + ',' + settingsDialog.data.currency, 'Link copied to clipboard!')"
|
||||
>{% raw
|
||||
%}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}},
|
||||
{{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw
|
||||
%}<q-tooltip> Click to copy URL </q-tooltip>
|
||||
</q-btn>
|
||||
|
||||
>{% raw
|
||||
%}{{location}}/lnurldevice/api/v1/lnurl/{{settingsDialog.data.id}},
|
||||
{{settingsDialog.data.key}}, {{settingsDialog.data.currency}}{% endraw
|
||||
%}<q-tooltip> Click to copy URL </q-tooltip>
|
||||
</q-btn>
|
||||
</center>
|
||||
<div class="text-subtitle2">
|
||||
<small> </small>
|
||||
</div>
|
||||
|
|
@ -191,6 +221,7 @@
|
|||
label="Type of device"
|
||||
></q-option-group>
|
||||
<q-input
|
||||
v-if="formDialoglnurldevice.data.device != 'switch'"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.profit"
|
||||
|
|
@ -198,7 +229,222 @@
|
|||
max="90"
|
||||
label="Profit margin (% added to invoices/deducted from faucets)"
|
||||
></q-input>
|
||||
<div v-else>
|
||||
<q-btn
|
||||
unelevated
|
||||
class="q-mb-lg"
|
||||
round
|
||||
size="sm"
|
||||
icon="add"
|
||||
@click="addSwitch"
|
||||
v-model="switches"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
class="q-mb-lg"
|
||||
round
|
||||
size="sm"
|
||||
icon="remove"
|
||||
@click="removeSwitch"
|
||||
v-model="switches"
|
||||
color="primary"
|
||||
></q-btn>
|
||||
|
||||
<div v-if="switches >= 0">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.profit"
|
||||
class="q-pb-md"
|
||||
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
|
||||
:mask="'#.##'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="'0.01'"
|
||||
value="0.00"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.amount"
|
||||
type="number"
|
||||
value="1000"
|
||||
label="milesecs to turn Switch on for (1sec = 1000ms)"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.pin"
|
||||
type="number"
|
||||
label="GPIO to turn on"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="switches >= 1">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.profit1"
|
||||
class="q-pb-md"
|
||||
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
|
||||
:mask="'#.##'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="'0.01'"
|
||||
value="0.00"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.amount1"
|
||||
type="number"
|
||||
value="1000"
|
||||
label="milesecs to turn Switch on for (1sec = 1000ms)"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.pin1"
|
||||
type="number"
|
||||
label="GPIO to turn on"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="switches >= 2">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.profit2"
|
||||
class="q-pb-md"
|
||||
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
|
||||
:mask="'#.##'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="'0.01'"
|
||||
value="0.00"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.amount2"
|
||||
type="number"
|
||||
value="1000"
|
||||
label="milesecs to turn Switch on for (1sec = 1000ms)"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.pin2"
|
||||
type="number"
|
||||
label="GPIO to turn on"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="switches >= 3">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.profit3"
|
||||
class="q-pb-md"
|
||||
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
|
||||
:mask="'#.##'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="'0.01'"
|
||||
value="0.00"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.amount3"
|
||||
type="number"
|
||||
value="1000"
|
||||
label="milesecs to turn Switch on for (1sec = 1000ms)"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.pin3"
|
||||
type="number"
|
||||
label="GPIO to turn on"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="switches >= 4">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<q-input
|
||||
ref="setAmount"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.profit4"
|
||||
class="q-pb-md"
|
||||
:label="'Amount (' + formDialoglnurldevice.data.currency + ') *'"
|
||||
:mask="'#.##'"
|
||||
fill-mask="0"
|
||||
reverse-fill-mask
|
||||
:step="'0.01'"
|
||||
value="0.00"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.amount4"
|
||||
type="number"
|
||||
value="1000"
|
||||
label="milesecs to turn Switch on for (1sec = 1000ms)"
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col q-ml-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialoglnurldevice.data.pin4"
|
||||
type="number"
|
||||
label="GPIO to turn on"
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
v-if="formDialoglnurldevice.data.id"
|
||||
|
|
@ -225,6 +471,46 @@
|
|||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<q-dialog v-model="qrCodeDialog.show" position="top">
|
||||
<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"
|
||||
:options="{width: 800}"
|
||||
class="rounded-borders"
|
||||
></qrcode>
|
||||
</q-responsive>
|
||||
<q-btn
|
||||
outline
|
||||
color="grey"
|
||||
@click="copyText(lnurlValue, 'LNURL copied to clipboard!')"
|
||||
>Copy LNURL</q-btn
|
||||
>
|
||||
<q-chip
|
||||
v-if="websocketMessage == 'WebSocket NOT supported by your Browser!' || websocketMessage == 'Connection closed'"
|
||||
clickable
|
||||
color="red"
|
||||
text-color="white"
|
||||
icon="error"
|
||||
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
|
||||
>
|
||||
<q-chip v-else clickable color="green" text-color="white" icon="check"
|
||||
>{% raw %}{{ wsMessage }}{% endraw %}</q-chip
|
||||
>
|
||||
<br />
|
||||
<div class="row q-mt-lg q-gutter-sm">
|
||||
<q-btn
|
||||
v-for="switch_ in qrCodeDialog.data.switches"
|
||||
outline
|
||||
color="primary"
|
||||
:label="'Switch PIN:' + switch_[0]"
|
||||
@click="lnurlValueFetch(switch_[3])"
|
||||
></q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
|
||||
|
|
@ -252,9 +538,15 @@
|
|||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
tab: 'mails',
|
||||
protocol: window.location.protocol,
|
||||
location: window.location.hostname,
|
||||
wslocation: window.location.hostname,
|
||||
filter: '',
|
||||
currency: 'USD',
|
||||
lnurlValue: '',
|
||||
websocketMessage: '',
|
||||
switches: 0,
|
||||
lnurldeviceLinks: [],
|
||||
lnurldeviceLinksObj: [],
|
||||
devices: [
|
||||
|
|
@ -265,6 +557,10 @@
|
|||
{
|
||||
label: 'ATM',
|
||||
value: 'atm'
|
||||
},
|
||||
{
|
||||
label: 'Switch',
|
||||
value: 'switch'
|
||||
}
|
||||
],
|
||||
lnurldevicesTable: {
|
||||
|
|
@ -299,12 +595,6 @@
|
|||
label: 'device',
|
||||
field: 'device'
|
||||
},
|
||||
{
|
||||
name: 'profit',
|
||||
align: 'left',
|
||||
label: 'profit',
|
||||
field: 'profit'
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
align: 'left',
|
||||
|
|
@ -333,7 +623,8 @@
|
|||
show_ack: false,
|
||||
show_price: 'None',
|
||||
device: 'pos',
|
||||
profit: 2,
|
||||
profit: 0,
|
||||
amount: 1,
|
||||
title: ''
|
||||
}
|
||||
},
|
||||
|
|
@ -343,7 +634,40 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
wsMessage: function () {
|
||||
return this.websocketMessage
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openQrCodeDialog: function (lnurldevice_id) {
|
||||
var lnurldevice = _.findWhere(this.lnurldeviceLinks, {
|
||||
id: lnurldevice_id
|
||||
})
|
||||
console.log(lnurldevice)
|
||||
this.qrCodeDialog.data = _.clone(lnurldevice)
|
||||
this.qrCodeDialog.data.url =
|
||||
window.location.protocol + '//' + window.location.host
|
||||
this.lnurlValueFetch(
|
||||
this.qrCodeDialog.data.switches[0][3],
|
||||
this.qrCodeDialog.data.id
|
||||
)
|
||||
this.qrCodeDialog.show = true
|
||||
},
|
||||
lnurlValueFetch: function (lnurl, switchId) {
|
||||
this.lnurlValue = lnurl
|
||||
this.websocketConnector(
|
||||
'wss://' + window.location.host + '/api/v1/ws/' + switchId
|
||||
)
|
||||
},
|
||||
addSwitch: function () {
|
||||
var self = this
|
||||
self.switches = self.switches + 1
|
||||
},
|
||||
removeSwitch: function () {
|
||||
var self = this
|
||||
self.switches = self.switches - 1
|
||||
},
|
||||
cancellnurldevice: function (data) {
|
||||
var self = this
|
||||
self.formDialoglnurldevice.show = false
|
||||
|
|
@ -400,6 +724,9 @@
|
|||
.then(function (response) {
|
||||
if (response.data) {
|
||||
self.lnurldeviceLinks = response.data.map(maplnurldevice)
|
||||
console.log('response.data')
|
||||
console.log(response.data)
|
||||
console.log('response.data')
|
||||
}
|
||||
})
|
||||
.catch(function (error) {
|
||||
|
|
@ -493,6 +820,25 @@
|
|||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
websocketConnector: function (websocketUrl) {
|
||||
if ('WebSocket' in window) {
|
||||
self = this
|
||||
var ws = new WebSocket(websocketUrl)
|
||||
self.updateWsMessage('Websocket connected')
|
||||
ws.onmessage = function (evt) {
|
||||
var received_msg = evt.data
|
||||
self.updateWsMessage('Message recieved: ' + received_msg)
|
||||
}
|
||||
ws.onclose = function () {
|
||||
self.updateWsMessage('Connection closed')
|
||||
}
|
||||
} else {
|
||||
self.updateWsMessage('WebSocket NOT supported by your Browser!')
|
||||
}
|
||||
},
|
||||
updateWsMessage: function (message) {
|
||||
this.websocketMessage = message
|
||||
},
|
||||
clearFormDialoglnurldevice() {
|
||||
this.formDialoglnurldevice.data = {
|
||||
lnurl_toggle: false,
|
||||
|
|
@ -519,6 +865,7 @@
|
|||
'//',
|
||||
window.location.host
|
||||
].join('')
|
||||
self.wslocation = ['ws://', window.location.host].join('')
|
||||
LNbits.api
|
||||
.request('GET', '/api/v1/currencies')
|
||||
.then(response => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
|
||||
import pyqrcode
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import HTMLResponse
|
||||
from starlette.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from lnbits.core.crud import update_payment_status
|
||||
from lnbits.core.models import User
|
||||
|
|
@ -51,3 +53,13 @@ async def displaypin(request: Request, paymentid: str = Query(None)):
|
|||
"lnurldevice/error.html",
|
||||
{"request": request, "pin": "filler", "not_paid": True},
|
||||
)
|
||||
|
||||
|
||||
@lnurldevice_ext.get("/img/{lnurldevice_id}", response_class=StreamingResponse)
|
||||
async def img(request: Request, lnurldevice_id):
|
||||
lnurldevice = await get_lnurldevice(lnurldevice_id)
|
||||
if not lnurldevice:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
|
||||
)
|
||||
return lnurldevice.lnurl(request)
|
||||
|
|
|
|||
|
|
@ -32,32 +32,42 @@ async def api_list_currencies_available():
|
|||
@lnurldevice_ext.post("/api/v1/lnurlpos")
|
||||
@lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}")
|
||||
async def api_lnurldevice_create_or_update(
|
||||
req: Request,
|
||||
data: createLnurldevice,
|
||||
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||
lnurldevice_id: str = Query(None),
|
||||
):
|
||||
if not lnurldevice_id:
|
||||
lnurldevice = await create_lnurldevice(data)
|
||||
return lnurldevice.dict()
|
||||
return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
|
||||
else:
|
||||
lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id)
|
||||
return lnurldevice.dict()
|
||||
return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
|
||||
|
||||
|
||||
@lnurldevice_ext.get("/api/v1/lnurlpos")
|
||||
async def api_lnurldevices_retrieve(wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
async def api_lnurldevices_retrieve(
|
||||
req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
|
||||
):
|
||||
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
|
||||
try:
|
||||
return [
|
||||
{**lnurldevice.dict()} for lnurldevice in await get_lnurldevices(wallet_ids)
|
||||
{**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
|
||||
for lnurldevice in await get_lnurldevices(wallet_ids)
|
||||
]
|
||||
except:
|
||||
return ""
|
||||
try:
|
||||
return [
|
||||
{**lnurldevice.dict()}
|
||||
for lnurldevice in await get_lnurldevices(wallet_ids)
|
||||
]
|
||||
except:
|
||||
return ""
|
||||
|
||||
|
||||
@lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}")
|
||||
async def api_lnurldevice_retrieve(
|
||||
request: Request,
|
||||
req: Request,
|
||||
wallet: WalletTypeInfo = Depends(get_key_type),
|
||||
lnurldevice_id: str = Query(None),
|
||||
):
|
||||
|
|
@ -68,7 +78,7 @@ async def api_lnurldevice_retrieve(
|
|||
)
|
||||
if not lnurldevice.lnurl_toggle:
|
||||
return {**lnurldevice.dict()}
|
||||
return {**lnurldevice.dict(), **{"lnurl": lnurldevice.lnurl(request=request)}}
|
||||
return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
|
||||
|
||||
|
||||
@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}")
|
||||
|
|
|
|||
|
|
@ -21,13 +21,15 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
|||
served_meta,
|
||||
served_pr,
|
||||
webhook_url,
|
||||
webhook_headers,
|
||||
webhook_body,
|
||||
success_text,
|
||||
success_url,
|
||||
comment_chars,
|
||||
currency,
|
||||
fiat_base_multiplier
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
{returning}
|
||||
""",
|
||||
(
|
||||
|
|
@ -36,6 +38,8 @@ async def create_pay_link(data: CreatePayLinkData, wallet_id: str) -> PayLink:
|
|||
data.min,
|
||||
data.max,
|
||||
data.webhook_url,
|
||||
data.webhook_headers,
|
||||
data.webhook_body,
|
||||
data.success_text,
|
||||
data.success_url,
|
||||
data.comment_chars,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ async def m001_initial(db):
|
|||
id {db.serial_primary_key},
|
||||
wallet TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
served_meta INTEGER NOT NULL,
|
||||
served_pr INTEGER NOT NULL
|
||||
);
|
||||
|
|
@ -60,3 +60,11 @@ async def m004_fiat_base_multiplier(db):
|
|||
await db.execute(
|
||||
"ALTER TABLE lnurlp.pay_links ADD COLUMN fiat_base_multiplier INTEGER DEFAULT 1;"
|
||||
)
|
||||
|
||||
|
||||
async def m005_webhook_headers_and_body(db):
|
||||
"""
|
||||
Add headers and body to webhooks
|
||||
"""
|
||||
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;")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class CreatePayLinkData(BaseModel):
|
|||
currency: str = Query(None)
|
||||
comment_chars: int = Query(0, ge=0, lt=800)
|
||||
webhook_url: str = Query(None)
|
||||
webhook_headers: str = Query(None)
|
||||
webhook_body: str = Query(None)
|
||||
success_text: str = Query(None)
|
||||
success_url: str = Query(None)
|
||||
fiat_base_multiplier: int = Query(100, ge=1)
|
||||
|
|
@ -31,6 +33,8 @@ class PayLink(BaseModel):
|
|||
served_meta: int
|
||||
served_pr: int
|
||||
webhook_url: Optional[str]
|
||||
webhook_headers: Optional[str]
|
||||
webhook_body: Optional[str]
|
||||
success_text: Optional[str]
|
||||
success_url: Optional[str]
|
||||
currency: Optional[str]
|
||||
|
|
|
|||
|
|
@ -33,17 +33,22 @@ async def on_invoice_paid(payment: Payment) -> None:
|
|||
if pay_link and pay_link.webhook_url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
r = await client.post(
|
||||
pay_link.webhook_url,
|
||||
json={
|
||||
kwargs = {
|
||||
"json": {
|
||||
"payment_hash": payment.payment_hash,
|
||||
"payment_request": payment.bolt11,
|
||||
"amount": payment.amount,
|
||||
"comment": payment.extra.get("comment"),
|
||||
"lnurlp": pay_link.id,
|
||||
},
|
||||
timeout=40,
|
||||
)
|
||||
"timeout": 40,
|
||||
}
|
||||
if pay_link.webhook_body:
|
||||
kwargs["json"]["body"] = json.loads(pay_link.webhook_body)
|
||||
if pay_link.webhook_headers:
|
||||
kwargs["headers"] = json.loads(pay_link.webhook_headers)
|
||||
|
||||
r = await client.post(pay_link.webhook_url, **kwargs)
|
||||
await mark_webhook_sent(payment, r.status_code)
|
||||
except (httpx.ConnectError, httpx.RequestError):
|
||||
await mark_webhook_sent(payment, -1)
|
||||
|
|
|
|||
|
|
@ -213,6 +213,24 @@
|
|||
label="Webhook URL (optional)"
|
||||
hint="A URL to be called whenever this link receives a payment."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-if="formDialog.data.webhook_url"
|
||||
v-model="formDialog.data.webhook_headers"
|
||||
type="text"
|
||||
label="Webhook headers (optional)"
|
||||
hint="Custom data as JSON string, send headers along with the webhook."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-if="formDialog.data.webhook_url"
|
||||
v-model="formDialog.data.webhook_body"
|
||||
type="text"
|
||||
label="Webhook custom data (optional)"
|
||||
hint="Custom data as JSON string, will get posted along with webhook 'body' field."
|
||||
></q-input>
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
from fastapi import Request
|
||||
|
|
@ -90,6 +91,24 @@ async def api_link_create_or_update(
|
|||
detail="Must use full satoshis.", status_code=HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
|
||||
if data.webhook_headers:
|
||||
try:
|
||||
json.loads(data.webhook_headers)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
detail="Invalid JSON in webhook_headers.",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
if data.webhook_body:
|
||||
try:
|
||||
json.loads(data.webhook_body)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
detail="Invalid JSON in webhook_body.",
|
||||
status_code=HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
# database only allows int4 entries for min and max. For fiat currencies,
|
||||
# we multiply by data.fiat_base_multiplier (usually 100) to save the value in cents.
|
||||
if data.currency and data.fiat_base_multiplier:
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ async def m001_initial(db):
|
|||
Initial lnurlpayouts table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE lnurlpayout.lnurlpayouts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
wallet TEXT NOT NULL,
|
||||
admin_key TEXT NOT NULL,
|
||||
lnurlpay TEXT NOT NULL,
|
||||
threshold INT NOT NULL
|
||||
threshold {db.big_int} NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ async def api_lnurlpayout_delete(
|
|||
)
|
||||
|
||||
await delete_lnurlpayout(lnurlpayout_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@lnurlpayout_ext.get("/api/v1/lnurlpayouts/{lnurlpayout_id}", status_code=HTTPStatus.OK)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
# type: ignore
|
||||
from os import getenv
|
||||
|
||||
from fastapi import Request
|
||||
|
|
@ -34,7 +35,9 @@ ngrok_tunnel = ngrok.connect(port)
|
|||
|
||||
|
||||
@ngrok_ext.get("/")
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists) # type: ignore
|
||||
):
|
||||
return ngrok_renderer().TemplateResponse(
|
||||
"ngrok/index.html", {"request": request, "ngrok": string5, "user": user.dict()}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
|
||||
[](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop')
|
||||
|
||||
LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
|
||||
LNbits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
|
||||
|
||||
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a costumer chooses an item, scans the QR code, gets the description and price. After payment, the costumer gets a confirmation code that the merchant can validate to be sure the payment was successful.
|
||||
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a customer chooses an item, scans the QR code, gets the description and price. After payment, the customer gets a confirmation code that the merchant can validate to be sure the payment was successful.
|
||||
|
||||
Costumers must use an LNURL pay capable wallet.
|
||||
Customers must use an LNURL pay capable wallet.
|
||||
|
||||
[**Wallets supporting LNURL**](https://github.com/fiatjaf/awesome-lnurl#wallets)
|
||||
|
||||
|
|
@ -18,18 +18,18 @@ Costumers must use an LNURL pay capable wallet.
|
|||

|
||||
2. Begin by creating an item, click "ADD NEW ITEM"
|
||||
- set the item name and a small description
|
||||
- you can set an optional, preferably square image, that will show up on the costumer wallet - _depending on wallet_
|
||||
- set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time costumer scans to pay\
|
||||
- you can set an optional, preferably square image, that will show up on the customer wallet - _depending on wallet_
|
||||
- set the item price, if you choose a fiat currency the bitcoin conversion will happen at the time customer scans to pay\
|
||||

|
||||
3. After creating some products, click on "PRINT QR CODES"\
|
||||

|
||||
4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\
|
||||
4. You'll see a QR code for each product in your LNbits Offline Shop with a title and price ready for printing\
|
||||

|
||||
5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet
|
||||
6. Choose what type of confirmation do you want costumers to report to merchant after a successful payment\
|
||||
6. Choose what type of confirmation do you want customers to report to merchant after a successful payment\
|
||||

|
||||
|
||||
- Wordlist is the default option: after a successful payment the costumer will receive a word from this list, **sequentially**. Starting in _albatross_ as costumers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\
|
||||
- Wordlist is the default option: after a successful payment the customer will receive a word from this list, **sequentially**. Starting in _albatross_ as customers pay for the items they will get the next word in the list until _zebra_, then it starts at the top again. The list can be changed, for example if you think A-Z is a big list to track, you can use _apple_, _banana_, _coconut_\
|
||||

|
||||
- TOTP (time-based one time password) can be used instead. If you use Google Authenticator just scan the presented QR with the app and after a successful payment the user will get the password that you can check with GA\
|
||||

|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ async def m001_initial(db):
|
|||
description TEXT NOT NULL,
|
||||
image TEXT, -- image/png;base64,...
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
price INTEGER NOT NULL,
|
||||
price {db.big_int} NOT NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'sat'
|
||||
);
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ async def api_add_or_update_item(
|
|||
async def api_delete_item(item_id, wallet: WalletTypeInfo = Depends(get_key_type)):
|
||||
shop = await get_or_create_shop_by_wallet(wallet.wallet.id)
|
||||
await delete_item_from_shop(shop.id, item_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
class CreateMethodData(BaseModel):
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ async def m001_initial(db):
|
|||
Initial paywalls table.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE paywall.paywalls (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
memo TEXT NOT NULL,
|
||||
amount INTEGER NOT NULL,
|
||||
amount {db.big_int} NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """
|
||||
|
|
@ -25,14 +25,14 @@ async def m002_redux(db):
|
|||
"""
|
||||
await db.execute("ALTER TABLE paywall.paywalls RENAME TO paywalls_old")
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE paywall.paywalls (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
memo TEXT NOT NULL,
|
||||
description TEXT NULL,
|
||||
amount INTEGER DEFAULT 0,
|
||||
amount {db.big_int} DEFAULT 0,
|
||||
time TIMESTAMP NOT NULL DEFAULT """
|
||||
+ db.timestamp_now
|
||||
+ """,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ async def api_paywall_delete(
|
|||
)
|
||||
|
||||
await delete_paywall(paywall_id)
|
||||
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
|
||||
return "", HTTPStatus.NO_CONTENT
|
||||
|
||||
|
||||
@paywall_ext.post("/api/v1/paywalls/invoice/{paywall_id}")
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from .models import (
|
|||
CreateSatsDiceLink,
|
||||
CreateSatsDicePayment,
|
||||
CreateSatsDiceWithdraw,
|
||||
HashCheck,
|
||||
satsdiceLink,
|
||||
satsdicePayment,
|
||||
satsdiceWithdraw,
|
||||
|
|
@ -76,7 +75,7 @@ async def get_satsdice_pays(wallet_ids: Union[str, List[str]]) -> List[satsdiceL
|
|||
return [satsdiceLink(**row) for row in rows]
|
||||
|
||||
|
||||
async def update_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]:
|
||||
async def update_satsdice_pay(link_id: str, **kwargs) -> satsdiceLink:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?",
|
||||
|
|
@ -85,10 +84,10 @@ async def update_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]:
|
|||
row = await db.fetchone(
|
||||
"SELECT * FROM satsdice.satsdice_pay WHERE id = ?", (link_id,)
|
||||
)
|
||||
return satsdiceLink(**row) if row else None
|
||||
return satsdiceLink(**row)
|
||||
|
||||
|
||||
async def increment_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLink]:
|
||||
async def increment_satsdice_pay(link_id: str, **kwargs) -> Optional[satsdiceLink]:
|
||||
q = ", ".join([f"{field[0]} = {field[0]} + ?" for field in kwargs.items()])
|
||||
await db.execute(
|
||||
f"UPDATE satsdice.satsdice_pay SET {q} WHERE id = ?",
|
||||
|
|
@ -100,7 +99,7 @@ async def increment_satsdice_pay(link_id: int, **kwargs) -> Optional[satsdiceLin
|
|||
return satsdiceLink(**row) if row else None
|
||||
|
||||
|
||||
async def delete_satsdice_pay(link_id: int) -> None:
|
||||
async def delete_satsdice_pay(link_id: str) -> None:
|
||||
await db.execute("DELETE FROM satsdice.satsdice_pay WHERE id = ?", (link_id,))
|
||||
|
||||
|
||||
|
|
@ -119,9 +118,15 @@ async def create_satsdice_payment(data: CreateSatsDicePayment) -> satsdicePaymen
|
|||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(data["payment_hash"], data["satsdice_pay"], data["value"], False, False),
|
||||
(
|
||||
data.payment_hash,
|
||||
data.satsdice_pay,
|
||||
data.value,
|
||||
False,
|
||||
False,
|
||||
),
|
||||
)
|
||||
payment = await get_satsdice_payment(data["payment_hash"])
|
||||
payment = await get_satsdice_payment(data.payment_hash)
|
||||
assert payment, "Newly created withdraw couldn't be retrieved"
|
||||
return payment
|
||||
|
||||
|
|
@ -134,9 +139,7 @@ async def get_satsdice_payment(payment_hash: str) -> Optional[satsdicePayment]:
|
|||
return satsdicePayment(**row) if row else None
|
||||
|
||||
|
||||
async def update_satsdice_payment(
|
||||
payment_hash: int, **kwargs
|
||||
) -> Optional[satsdicePayment]:
|
||||
async def update_satsdice_payment(payment_hash: str, **kwargs) -> satsdicePayment:
|
||||
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
|
||||
|
||||
await db.execute(
|
||||
|
|
@ -147,7 +150,7 @@ async def update_satsdice_payment(
|
|||
"SELECT * FROM satsdice.satsdice_payment WHERE payment_hash = ?",
|
||||
(payment_hash,),
|
||||
)
|
||||
return satsdicePayment(**row) if row else None
|
||||
return satsdicePayment(**row)
|
||||
|
||||
|
||||
##################SATSDICE WITHDRAW LINKS
|
||||
|
|
@ -168,16 +171,16 @@ async def create_satsdice_withdraw(data: CreateSatsDiceWithdraw) -> satsdiceWith
|
|||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
data["payment_hash"],
|
||||
data["satsdice_pay"],
|
||||
data["value"],
|
||||
data.payment_hash,
|
||||
data.satsdice_pay,
|
||||
data.value,
|
||||
urlsafe_short_hash(),
|
||||
urlsafe_short_hash(),
|
||||
int(datetime.now().timestamp()),
|
||||
data["used"],
|
||||
data.used,
|
||||
),
|
||||
)
|
||||
withdraw = await get_satsdice_withdraw(data["payment_hash"], 0)
|
||||
withdraw = await get_satsdice_withdraw(data.payment_hash, 0)
|
||||
assert withdraw, "Newly created withdraw couldn't be retrieved"
|
||||
return withdraw
|
||||
|
||||
|
|
@ -247,7 +250,7 @@ async def delete_satsdice_withdraw(withdraw_id: str) -> None:
|
|||
)
|
||||
|
||||
|
||||
async def create_withdraw_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
|
||||
async def create_withdraw_hash_check(the_hash: str, lnurl_id: str):
|
||||
await db.execute(
|
||||
"""
|
||||
INSERT INTO satsdice.hash_checkw (
|
||||
|
|
@ -262,19 +265,15 @@ async def create_withdraw_hash_check(the_hash: str, lnurl_id: str) -> HashCheck:
|
|||
return hashCheck
|
||||
|
||||
|
||||
async def get_withdraw_hash_checkw(the_hash: str, lnurl_id: str) -> Optional[HashCheck]:
|
||||
async def get_withdraw_hash_checkw(the_hash: str, lnurl_id: str):
|
||||
rowid = await db.fetchone(
|
||||
"SELECT * FROM satsdice.hash_checkw WHERE id = ?", (the_hash,)
|
||||
)
|
||||
rowlnurl = await db.fetchone(
|
||||
"SELECT * FROM satsdice.hash_checkw WHERE lnurl_id = ?", (lnurl_id,)
|
||||
)
|
||||
if not rowlnurl:
|
||||
if not rowlnurl or not rowid:
|
||||
await create_withdraw_hash_check(the_hash, lnurl_id)
|
||||
return {"lnurl": True, "hash": False}
|
||||
else:
|
||||
if not rowid:
|
||||
await create_withdraw_hash_check(the_hash, lnurl_id)
|
||||
return {"lnurl": True, "hash": False}
|
||||
else:
|
||||
return {"lnurl": True, "hash": True}
|
||||
return {"lnurl": True, "hash": True}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from http import HTTPStatus
|
||||
|
|
@ -83,15 +82,18 @@ async def api_lnurlp_callback(
|
|||
|
||||
success_action = link.success_action(payment_hash=payment_hash, req=req)
|
||||
|
||||
data: CreateSatsDicePayment = {
|
||||
"satsdice_pay": link.id,
|
||||
"value": amount_received / 1000,
|
||||
"payment_hash": payment_hash,
|
||||
}
|
||||
data = CreateSatsDicePayment(
|
||||
satsdice_pay=link.id,
|
||||
value=amount_received / 1000,
|
||||
payment_hash=payment_hash,
|
||||
)
|
||||
|
||||
await create_satsdice_payment(data)
|
||||
payResponse = {"pr": payment_request, "successAction": success_action, "routes": []}
|
||||
|
||||
payResponse: dict = {
|
||||
"pr": payment_request,
|
||||
"successAction": success_action,
|
||||
"routes": [],
|
||||
}
|
||||
return json.dumps(payResponse)
|
||||
|
||||
|
||||
|
|
@ -133,9 +135,7 @@ async def api_lnurlw_response(req: Request, unique_hash: str = Query(None)):
|
|||
name="satsdice.api_lnurlw_callback",
|
||||
)
|
||||
async def api_lnurlw_callback(
|
||||
req: Request,
|
||||
unique_hash: str = Query(None),
|
||||
k1: str = Query(None),
|
||||
pr: str = Query(None),
|
||||
):
|
||||
|
||||
|
|
@ -146,12 +146,13 @@ async def api_lnurlw_callback(
|
|||
return {"status": "ERROR", "reason": "spent"}
|
||||
paylink = await get_satsdice_pay(link.satsdice_pay)
|
||||
|
||||
await update_satsdice_withdraw(link.id, used=1)
|
||||
await pay_invoice(
|
||||
wallet_id=paylink.wallet,
|
||||
payment_request=pr,
|
||||
max_sat=link.value,
|
||||
extra={"tag": "withdraw"},
|
||||
)
|
||||
if paylink:
|
||||
await update_satsdice_withdraw(link.id, used=1)
|
||||
await pay_invoice(
|
||||
wallet_id=paylink.wallet,
|
||||
payment_request=pr,
|
||||
max_sat=link.value,
|
||||
extra={"tag": "withdraw"},
|
||||
)
|
||||
|
||||
return {"status": "OK"}
|
||||
return {"status": "OK"}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@ async def m001_initial(db):
|
|||
Creates an improved satsdice table and migrates the existing data.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE satsdice.satsdice_pay (
|
||||
id TEXT PRIMARY KEY,
|
||||
wallet TEXT,
|
||||
title TEXT,
|
||||
min_bet INTEGER,
|
||||
max_bet INTEGER,
|
||||
amount INTEGER DEFAULT 0,
|
||||
amount {db.big_int} DEFAULT 0,
|
||||
served_meta INTEGER NOT NULL,
|
||||
served_pr INTEGER NOT NULL,
|
||||
multiplier FLOAT,
|
||||
|
|
@ -28,11 +28,11 @@ async def m002_initial(db):
|
|||
Creates an improved satsdice table and migrates the existing data.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE satsdice.satsdice_withdraw (
|
||||
id TEXT PRIMARY KEY,
|
||||
satsdice_pay TEXT,
|
||||
value INTEGER DEFAULT 1,
|
||||
value {db.big_int} DEFAULT 1,
|
||||
unique_hash TEXT UNIQUE,
|
||||
k1 TEXT,
|
||||
open_time INTEGER,
|
||||
|
|
@ -47,11 +47,11 @@ async def m003_initial(db):
|
|||
Creates an improved satsdice table and migrates the existing data.
|
||||
"""
|
||||
await db.execute(
|
||||
"""
|
||||
f"""
|
||||
CREATE TABLE satsdice.satsdice_payment (
|
||||
payment_hash TEXT PRIMARY KEY,
|
||||
satsdice_pay TEXT,
|
||||
value INTEGER,
|
||||
value {db.big_int},
|
||||
paid BOOL DEFAULT FALSE,
|
||||
lost BOOL DEFAULT FALSE
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import Dict, Optional
|
|||
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from lnurl import Lnurl, LnurlWithdrawResponse
|
||||
from lnurl import Lnurl
|
||||
from lnurl import encode as lnurl_encode # type: ignore
|
||||
from lnurl.types import LnurlPayMetadata # type: ignore
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -80,8 +80,7 @@ class satsdiceWithdraw(BaseModel):
|
|||
def is_spent(self) -> bool:
|
||||
return self.used >= 1
|
||||
|
||||
@property
|
||||
def lnurl_response(self, req: Request) -> LnurlWithdrawResponse:
|
||||
def lnurl_response(self, req: Request):
|
||||
url = req.url_for("satsdice.api_lnurlw_callback", unique_hash=self.unique_hash)
|
||||
withdrawResponse = {
|
||||
"tag": "withdrawRequest",
|
||||
|
|
@ -99,7 +98,7 @@ class HashCheck(BaseModel):
|
|||
lnurl_id: str
|
||||
|
||||
@classmethod
|
||||
def from_row(cls, row: Row) -> "Hash":
|
||||
def from_row(cls, row: Row):
|
||||
return cls(**dict(row))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import random
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
|
||||
import pyqrcode
|
||||
from fastapi import Request
|
||||
from fastapi.param_functions import Query
|
||||
from fastapi.params import Depends
|
||||
|
|
@ -20,13 +22,15 @@ from .crud import (
|
|||
get_satsdice_withdraw,
|
||||
update_satsdice_payment,
|
||||
)
|
||||
from .models import CreateSatsDiceWithdraw, satsdiceLink
|
||||
from .models import CreateSatsDiceWithdraw
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@satsdice_ext.get("/", response_class=HTMLResponse)
|
||||
async def index(request: Request, user: User = Depends(check_user_exists)):
|
||||
async def index(
|
||||
request: Request, user: User = Depends(check_user_exists) # type: ignore
|
||||
):
|
||||
return satsdice_renderer().TemplateResponse(
|
||||
"satsdice/index.html", {"request": request, "user": user.dict()}
|
||||
)
|
||||
|
|
@ -67,7 +71,7 @@ async def displaywin(
|
|||
)
|
||||
withdrawLink = await get_satsdice_withdraw(payment_hash)
|
||||
payment = await get_satsdice_payment(payment_hash)
|
||||
if payment.lost:
|
||||
if not payment or payment.lost:
|
||||
return satsdice_renderer().TemplateResponse(
|
||||
"satsdice/error.html",
|
||||
{"request": request, "link": satsdicelink.id, "paid": False, "lost": True},
|
||||
|
|
@ -96,13 +100,18 @@ async def displaywin(
|
|||
)
|
||||
await update_satsdice_payment(payment_hash, paid=1)
|
||||
paylink = await get_satsdice_payment(payment_hash)
|
||||
if not paylink:
|
||||
return satsdice_renderer().TemplateResponse(
|
||||
"satsdice/error.html",
|
||||
{"request": request, "link": satsdicelink.id, "paid": False, "lost": True},
|
||||
)
|
||||
|
||||
data: CreateSatsDiceWithdraw = {
|
||||
"satsdice_pay": satsdicelink.id,
|
||||
"value": paylink.value * satsdicelink.multiplier,
|
||||
"payment_hash": payment_hash,
|
||||
"used": 0,
|
||||
}
|
||||
data = CreateSatsDiceWithdraw(
|
||||
satsdice_pay=satsdicelink.id,
|
||||
value=paylink.value * satsdicelink.multiplier,
|
||||
payment_hash=payment_hash,
|
||||
used=0,
|
||||
)
|
||||
|
||||
withdrawLink = await create_satsdice_withdraw(data)
|
||||
return satsdice_renderer().TemplateResponse(
|
||||
|
|
@ -121,9 +130,12 @@ async def displaywin(
|
|||
|
||||
@satsdice_ext.get("/img/{link_id}", response_class=HTMLResponse)
|
||||
async def img(link_id):
|
||||
link = await get_satsdice_pay(link_id) or abort(
|
||||
HTTPStatus.NOT_FOUND, "satsdice link does not exist."
|
||||
)
|
||||
link = await get_satsdice_pay(link_id)
|
||||
if not link:
|
||||
raise HTTPException(
|
||||
status_code=HTTPStatus.NOT_FOUND, detail="satsdice link does not exist."
|
||||
)
|
||||
|
||||
qr = pyqrcode.create(link.lnurl)
|
||||
stream = BytesIO()
|
||||
qr.svg(stream, scale=3)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue