Merge branch 'main' into fix/DAImageSize

This commit is contained in:
ben 2023-01-09 19:24:09 +00:00
commit aee742a8ac
50 changed files with 2895 additions and 355 deletions

View File

@ -7,29 +7,29 @@ LNbits
![Lightning network wallet](https://i.imgur.com/EHvK6Lq.png)
# LNbits v0.9 BETA, free and open-source lightning-network wallet/accounts system
# LNbits v0.9 BETA, free and open-source Lightning wallet accounts system
(Join us on [https://t.me/lnbits](https://t.me/lnbits))
(LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me)
LNbits is beta, for responsible disclosure of any concerns please contact lnbits@pm.me
Use [legend.lnbits.com](https://legend.lnbits.com), or run your own LNbits server!
LNbits is a very simple Python server that sits on top of any funding source, and can be used as:
LNbits is a Python server that sits on top of any funding source. It can be used as:
* Accounts system to mitigate the risk of exposing applications to your full balance, via unique API keys for each wallet
* Extendable platform for exploring lightning-network functionality via LNbits extension framework
* Accounts system to mitigate the risk of exposing applications to your full balance via unique API keys for each wallet
* Extendable platform for exploring Lightning network functionality via the LNbits extension framework
* Part of a development stack via LNbits API
* Fallback wallet for the LNURL scheme
* Instant wallet for LN demonstrations
LNbits can run on top of any lightning-network funding source, currently there is support for LND, c-lightning, Spark, LNpay, OpenNode, lntxbot, with more being added regularly.
LNbits can run on top of any Lightning funding source. It supports LND, CLN, Eclair, Spark, LNpay, OpenNode, lntxbot, LightningTipBot, and with more being added regularly.
See [docs.lnbits.org](https://docs.lnbits.org) for more detailed documentation.
Checkout the LNbits [YouTube](https://www.youtube.com/playlist?list=PLPj3KCksGbSYG0ciIQUWJru1dWstPHshe) video series.
LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as excellent funding sources for LNbits.
LNbits is inspired by all the great work of [opennode.com](https://www.opennode.com/), and in particular [lnpay.co](https://lnpay.co/). Both work as funding sources for LNbits.
## Running LNbits
@ -58,16 +58,15 @@ Example use would be an ATM, which utilises LNURL, if the user scans the QR with
![lnurl ATM](https://i.imgur.com/Gi6bn3L.jpg)
## LNbits as an insta-wallet
## LNbits as an instant wallet
Wallets can be easily generated and given out to people at events (one click multi-wallet generation to be added soon).
"Go to this website", has a lot less friction than "Download this app".
Wallets can be easily generated and given out to people at events. "Go to this website", has a lot less friction than "Download this app".
![lnurl ATM](https://i.imgur.com/xFWDnwy.png)
## Tip us
If you like this project and might even use or extend it, why not [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
If you like this project [send some tip love](https://legend.lnbits.com/paywall/GAqKguK5S8f6w5VNjS9DfK)!
[docs]: https://docs.lnbits.org/

View File

@ -206,6 +206,10 @@ poetry add setuptools wheel
./venv/bin/pip install setuptools wheel
```
#### Poetry
If your Poetry version is older than 1.2, for `poetry install`, ignore the `--only main` flag.
### Optional: PostgreSQL database
If you want to use LNbits at scale, we recommend using PostgreSQL as the backend database. Install Postgres and setup a database for LNbits:

View File

@ -0,0 +1,11 @@
# Deezy: Home for Lightning Liquidity
Swap lightning bitcoin for on-chain bitcoin to get inbound liquidity. Or get an on-chain deposit address for your lightning address.
* [Website](https://deezy.io)
* [Lightning Node](https://amboss.space/node/024bfaf0cabe7f874fd33ebf7c6f4e5385971fc504ef3f492432e9e3ec77e1b5cf)
* [Documentation](https://docs.deezy.io)
* [Discord](https://discord.gg/nEBbrUAvPy)
# Usage
This extension lets you swap lightning btc for on-chain btc and vice versa.
* Swap Lightning -> BTC to get inbound liquidity
* Swap BTC -> Lightning to generate an on-chain deposit address for your lightning address

View File

@ -0,0 +1,25 @@
from fastapi import APIRouter
from starlette.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
db = Database("ext_deezy")
deezy_ext: APIRouter = APIRouter(prefix="/deezy", tags=["deezy"])
deezy_static_files = [
{
"path": "/deezy/static",
"app": StaticFiles(directory="lnbits/extensions/deezy/static"),
"name": "deezy_static",
}
]
def deezy_renderer():
return template_renderer(["lnbits/extensions/deezy/templates"])
from .views import * # noqa
from .views_api import * # noqa

View File

@ -0,0 +1,6 @@
{
"name": "Deezy",
"short_description": "LN to onchain, onchain to LN swaps",
"tile": "/deezy/static/deezy.png",
"contributors": ["Uthpala"]
}

View File

@ -0,0 +1,115 @@
from http import HTTPStatus
from typing import List, Optional
from . import db
from .models import BtcToLnSwap, LnToBtcSwap, Token, UpdateLnToBtcSwap
async def get_ln_to_btc() -> List[LnToBtcSwap]:
rows = await db.fetchall(
f"SELECT * FROM deezy.ln_to_btc_swap ORDER BY created_at DESC",
)
return [LnToBtcSwap(**row) for row in rows]
async def get_btc_to_ln() -> List[BtcToLnSwap]:
rows = await db.fetchall(
f"SELECT * FROM deezy.btc_to_ln_swap ORDER BY created_at DESC",
)
return [BtcToLnSwap(**row) for row in rows]
async def get_token() -> Optional[Token]:
row = await db.fetchone(
f"SELECT * FROM deezy.token ORDER BY created_at DESC",
)
return Token(**row) if row else None
async def save_token(
data: Token,
) -> Token:
await db.execute(
"""
INSERT INTO deezy.token (
deezy_token
)
VALUES (?)
""",
(data.deezy_token,),
)
return data
async def save_ln_to_btc(
data: LnToBtcSwap,
) -> LnToBtcSwap:
return await db.execute(
"""
INSERT INTO deezy.ln_to_btc_swap (
amount_sats,
on_chain_address,
on_chain_sats_per_vbyte,
bolt11_invoice,
fee_sats,
txid,
tx_hex
)
VALUES (?,?,?,?,?,?,?)
""",
(
data.amount_sats,
data.on_chain_address,
data.on_chain_sats_per_vbyte,
data.bolt11_invoice,
data.fee_sats,
data.txid,
data.tx_hex,
),
)
async def update_ln_to_btc(data: UpdateLnToBtcSwap) -> str:
await db.execute(
"""
UPDATE deezy.ln_to_btc_swap
SET txid = ?, tx_hex = ?
WHERE bolt11_invoice = ?
""",
(data.txid, data.tx_hex, data.bolt11_invoice),
)
return data.txid
async def save_btc_to_ln(
data: BtcToLnSwap,
) -> BtcToLnSwap:
return await db.execute(
"""
INSERT INTO deezy.btc_to_ln_swap (
ln_address,
on_chain_address,
secret_access_key,
commitment,
signature
)
VALUES (?,?,?,?,?)
""",
(
data.ln_address,
data.on_chain_address,
data.secret_access_key,
data.commitment,
data.signature,
),
)

View File

@ -0,0 +1,37 @@
async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE deezy.ln_to_btc_swap (
id TEXT PRIMARY KEY,
amount_sats {db.big_int} NOT NULL,
on_chain_address TEXT NOT NULL,
on_chain_sats_per_vbyte INT NOT NULL,
bolt11_invoice TEXT NOT NULL,
fee_sats {db.big_int} NOT NULL,
txid TEXT NULL,
tx_hex TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)
await db.execute(
f"""
CREATE TABLE deezy.btc_to_ln_swap (
id TEXT PRIMARY KEY,
ln_address TEXT NOT NULL,
on_chain_address TEXT NOT NULL,
secret_access_key TEXT NOT NULL,
commitment TEXT NOT NULL,
signature TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)
await db.execute(
f"""
CREATE TABLE deezy.token (
deezy_token TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
"""
)

View File

@ -0,0 +1,34 @@
from typing import Optional
from pydantic.main import BaseModel
from sqlalchemy.engine import base # type: ignore
class Token(BaseModel):
deezy_token: str
class LnToBtcSwap(BaseModel):
amount_sats: int
on_chain_address: str
on_chain_sats_per_vbyte: int
bolt11_invoice: str
fee_sats: int
txid: str = ""
tx_hex: str = ""
created_at: str = ""
class UpdateLnToBtcSwap(BaseModel):
txid: str
tx_hex: str
bolt11_invoice: str
class BtcToLnSwap(BaseModel):
ln_address: str
on_chain_address: str
secret_access_key: str
commitment: str
signature: str
created_at: str = ""

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -0,0 +1,253 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About Deezy"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<img
alt=""
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgNTMwLjA5IDEzNi43MyI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7fS5jbHMtMntmaWxsLXJ1bGU6ZXZlbm9kZDtmaWxsOnVybCgjbGluZWFyLWdyYWRpZW50KTt9LmNscy0ze2ZpbGw6I2ZmYzkyYjt9PC9zdHlsZT48bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhci1ncmFkaWVudCIgeDE9IjUxLjY5IiB5MT0iMzEuNjciIHgyPSIxODAuMjMiIHkyPSIxMDUuMTIiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBvZmZzZXQ9IjAiIHN0b3AtY29sb3I9IiNmZmYyMWYiLz48c3RvcCBvZmZzZXQ9IjAuMjkiIHN0b3AtY29sb3I9IiNmZmNkMmQiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiNmNzkyMzMiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48ZyBpZD0iTGF5ZXJfMiIgZGF0YS1uYW1lPSJMYXllciAyIj48ZyBpZD0iTGF5ZXJfMS0yIiBkYXRhLW5hbWU9IkxheWVyIDEiPjxwYXRoIGNsYXNzPSJjbHMtMSIgZD0iTTYxLjg5LDBoNTcuNTVDMTMzLjksMCwxNDUsMS40NCwxNTIuOTIsNC4zM2MxNC4yMSw1LjA1LDIzLjYsMTQuMTgsMjguNjYsMjcuNjRMMTUyLjY4LDQ2LjRsLS4yMy0uNDhjLTIuMTgtNi43NC01LjA2LTExLjU0LTguNDMtMTQuOUEyNS40MywyNS40MywwLDAsMCwxMzIsMjUuNDlsLS4yNC0yLjg5LTMuMTMsMi4xNmE1NC4xMSw1NC4xMSwwLDAsMC05LjE2LS40OEg5MC43OVY1MUw2MS44OSw3MC42OFptMTI1LDU0LjgxQTEyNC43NiwxMjQuNzYsMCwwLDEsMTg3LjYsNjhhMTA4LjM4LDEwOC4zOCwwLDAsMS01LjMsMzQuNjJjLTMuMzcsMTEuMy05LjM5LDE5LjQ3LTE3LjU4LDI0Ljc2YTQ2LjE4LDQ2LjE4LDAsMCwxLTE3LjA5LDYuNDljLTYsMS4yLTE1LjQxLDEuNjgtMjguMTksMS42OEg2MS44OVY5OS4yOWwyOC45LTE0LjQzdjI2LjY5aDExLjU2bC4yNCwyLjE2LDMuMzctMi4xNmgxMy40OGMxMi43OCwwLDIxLjQ0LTIuODksMjYuMjYtOC40MiwzLjEzLTMuNiw1LjU0LTguNDEsNy4yMi0xNC45YTU0LjI4LDU0LjI4LDAsMCwwLDIuNDEtMTEuM1oiLz48cG9seWdvbiBjbGFzcz0iY2xzLTIiIHBvaW50cz0iMCAxMjIuMTMgMTI1LjcxIDM1LjU4IDEyOC44NSA2Ni41OSAyMzEuOTIgMTQuNjcgMTA4LjM3IDEwMC45NyAxMDQuNzYgNjkuNzEgMCAxMjIuMTMiLz48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Ik0yNjYuNjksMjguNjhoMTN2ODRoLTEzVjEwNHEtNy4zMiwxMC4yLTIxLDEwLjJhMjguMTQsMjguMTQsMCwwLDEtMjEuMTItOS4xOCwzMS4yMSwzMS4yMSwwLDAsMS04Ljc2LTIyLjM4LDMxLjE1LDMxLjE1LDAsMCwxLDguNzYtMjIuNDQsMjguMjMsMjguMjMsMCwwLDEsMjEuMTItOS4xMnExMy42OCwwLDIxLDEwLjA4Wk0yMzQuMTcsOTYuNDJhMTkuNTcsMTkuNTcsMCwwLDAsMjcuMTIsMCwxOC43NCwxOC43NCwwLDAsMCw1LjQtMTMuNzQsMTguNzQsMTguNzQsMCwwLDAtNS40LTEzLjc0LDE5LjU3LDE5LjU3LDAsMCwwLTI3LjEyLDAsMTguNzQsMTguNzQsMCwwLDAtNS40LDEzLjc0QTE4Ljc0LDE4Ljc0LDAsMCwwLDIzNC4xNyw5Ni40MloiLz48cGF0aCBjbGFzcz0iY2xzLTMiIGQ9Ik0zMDIsODguMmExNi40OCwxNi40OCwwLDAsMCw2LjYsMTAuNSwyMS4yMiwyMS4yMiwwLDAsMCwxMi42LDMuNjZxMTAuMzIsMCwxNS40OC03LjQ0bDEwLjY4LDYuMjRxLTguODgsMTMuMDgtMjYuMjgsMTMuMDgtMTQuNjQsMC0yMy42NC04Ljk0dC05LTIyLjYycTAtMTMuNDQsOC44OC0yMi41dDIyLjgtOS4wNnExMy4yLDAsMjEuNjYsOS4yNGEzMiwzMiwwLDAsMSw4LjQ2LDIyLjQ0LDQwLjA5LDQwLjA5LDAsMCwxLS40OCw1LjRabS0uMTItMTAuNTZoMzUuMjhxLTEuMzItNy4zMi02LjA2LTExQTE3LjQ1LDE3LjQ1LDAsMCwwLDMyMCw2Mi44OGExOC4yMywxOC4yMywwLDAsMC0xMiw0QTE3Ljg2LDE3Ljg2LDAsMCwwLDMwMS44NSw3Ny42NFoiLz48cGF0aCBjbGFzcz0iY2xzLTEiIGQ9Ik0zNjguNDUsODguMmExNi40OCwxNi40OCwwLDAsMCw2LjYsMTAuNSwyMS4yMiwyMS4yMiwwLDAsMCwxMi42LDMuNjZxMTAuMzIsMCwxNS40OC03LjQ0bDEwLjY4LDYuMjRxLTguODgsMTMuMDgtMjYuMjgsMTMuMDgtMTQuNjQsMC0yMy42NC04Ljk0dC05LTIyLjYycTAtMTMuNDQsOC44OC0yMi41dDIyLjgtOS4wNnExMy4yLDAsMjEuNjYsOS4yNGEzMiwzMiwwLDAsMSw4LjQ2LDIyLjQ0LDQwLjA5LDQwLjA5LDAsMCwxLS40OCw1LjRabS0uMTItMTAuNTZoMzUuMjhxLTEuMzItNy4zMi02LjA2LTExYTE3LjQ1LDE3LjQ1LDAsMCwwLTExLjEtMy43MiwxOC4yMywxOC4yMywwLDAsMC0xMiw0QTE3Ljg2LDE3Ljg2LDAsMCwwLDM2OC4zMyw3Ny42NFoiLz48cGF0aCBjbGFzcz0iY2xzLTMiIGQ9Ik00MzcuNTgsMTAwLjQ0aDI5LjE2djEyLjI0SDQxOS45M1YxMDRMNDQ4LDY0LjkySDQyMS4xM1Y1Mi42OGg0NC4zOXY4LjYzWiIvPjxwYXRoIGNsYXNzPSJjbHMtMSIgZD0iTTUxNi4yOSw1Mi42OGgxMy44bC0yMyw2MS45MnEtOC42NCwyMy4yOC0yOS4yOCwyMi4wOFYxMjQuNTZxNi4xMi4zNiw5Ljg0LTIuNTh0Ni4xMi05LjE4bC42LTEuMkw0NjguODksNTIuNjhoMTQuMTZsMTcuODksNDMuNTVaIi8+PC9nPjwvZz48L3N2Zz4="
height="40"
class="d-inline-block align-top my-2"
/>
<h5 class="text-subtitle1 q-my-none">
Deezy.io: Do onchain to offchain and vice-versa swaps
</h5>
<p>
Link :
<a class="text-light-blue" target="_blank" href="https://deezy.io/">
https://deezy.io/
</a>
</p>
<p>
<a class="text-light-blue" target="_blank" href="https://docs.deezy.io/"
>API DOCS</a
>
</p>
<p>
<small
>Created by,
<a
class="text-light-blue"
target="_blank"
href="https://twitter.com/Uthpala_419"
>Uthpala</a
></small
>
</p>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-expansion-item
group="swap-ln-to-btc"
dense
expand-separator
label="Swap (LIGHTNING TO BTC)"
:content-inset-level="0.5"
>
<q-expansion-item group="ln-to-btc" dense expand-separator label="GET Info">
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Get the current info about the swap service for converting LN btc to
on-chain BTC.
</h5>
<code class="text-light-blue">
<span class="text-white">GET (mainnet)</span>
https://api.deezy.io/v1/swap/info
</code>
<br />
<code class="text-light-blue">
<span class="text-white">GET (testnet)</span>
https://api-testnet.deezy.io/v1/swap/info
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"liquidity_fee_ppm": 2000,
"on_chain_bytes_estimate": 300,
"max_swap_amount_sats": 100000000,
"min_swap_amount_sats": 100000,
"available": true
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="ln-to-btc"
dense
expand-separator
label="POST New (LN to BTC) Swap"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Initiate a new swap to send lightning btc in exchange for on-chain
btc
</h5>
<code class="text-light-blue">
<span class="text-white">POST (mainnet)</span>
https://api.deezy.io/v1/swap
</code>
<br />
<code class="text-light-blue">
<span class="text-white">POST (testnet)</span>
https://api-testnet.deezy.io/v1/swap
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Payload</h6>
<pre>
{
"amount_sats": 500000,
"on_chain_address": "tb1qrcdhlm0m...",
"on_chain_sats_per_vbyte": 2
}
</pre>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"bolt11_invoice": "lntb603u1p3vmxj7p...",
"fee_sats": 600
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="ln-to-btc"
dense
expand-separator
label="GET Lookup (LN to BTC) Swap"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Lookup the on-chain transaction information for an existing swap
</h5>
<code class="text-light-blue">
<span class="text-white">GET (mainnet)</span>
https://api.deezy.io/v1/swap/lookup
</code>
<br />
<code class="text-light-blue">
<span class="text-white">GET (testnet)</span>
https://api-testnet.deezy.io/v1/swap/lookup
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Query Parameter</h6>
<pre>
"bolt11_invoice": "lntb603u1p3vmxj7pp54...",
</pre>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"on_chain_txid": "string",
"tx_hex": "string"
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
<q-expansion-item
group="swap-btc-to-ln"
dense
expand-separator
label="Swap (BTC TO LIGHTNING)"
:content-inset-level="0.5"
>
<q-expansion-item
group="btc-to-ln"
dense
expand-separator
label="POST New On-Chain Deposit Address"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Generate an on-chain deposit address for your lnurl or lightning
address.
</h5>
<code class="text-light-blue">
<span class="text-white">POST (mainnet)</span>
https://api.deezy.io/v1/source
</code>
<br />
<code class="text-light-blue">
<span class="text-white">POST (testnet)</span>
https://api-testnet.deezy.io/v1/source
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Payload</h6>
<pre>
{
"lnurl_or_lnaddress": "LNURL1DP68GURN8GHJ...",
"secret_access_key": "b3c6056d2845867fa7..",
"webhook_url": "https://your.website.com/dee.."
}
</pre>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"address": "bc1qkceyc5...",
"secret_access_key": "b3c6056d28458...",
"commitment": "for any satoshis sent to bc1..",
"signature": "d69j6aj1ssz5egmsr..",
"webhook_url": "https://your.website.com/deez.."
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="btc-to-ln"
dense
expand-separator
label="GET Lookup (BTC to LN) Swaps"
>
<q-card>
<q-card-section>
<h5 class="text-caption q-mt-sm q-mb-none">
Lookup (BTC to LN) swaps
</h5>
<code class="text-light-blue">
<span class="text-white">GET (mainnet)</span>
https://api.deezy.io/v1/source/lookup
</code>
<br />
<code class="text-light-blue">
<span class="text-white">GET (testnet)</span>
https://api-testnet.deezy.io/v1/source/lookup
</code>
<h6 class="text-caption q-mt-sm q-mb-none">Response</h6>
<pre>
{
"swaps": [
{
"lnurl_or_lnaddress": "string",
"deposit_address": "string",
"utxo_key": "string",
"deposit_amount_sats": 0,
"target_payout_amount_sats": 0,
"paid_amount_sats": 0,
"deezy_fee_sats": 0,
"status": "string"
}
],
"total_sent_sats": 0,
"total_received_sats": 0,
"total_pending_payout_sats": 0,
"total_deezy_fees_sats": 0
}
</pre>
</q-card-section>
</q-card>
</q-expansion-item>
</q-expansion-item>
</q-expansion-item>

View File

@ -0,0 +1,588 @@
{% 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>
<h5 class="text-subtitle1 q-mt-none q-mb-md">Deezy</h5>
<p class="text-subtitle2 q-mt-none q-mb-md">
An access token is required to use the swap service. Email
support@deezy.io or contact @dannydeezy on telegram to get one.
</p>
<div>
<div class="flex justify-between items-center">
<span>Deezy token </span>
<q-btn
type="button"
@click="showDeezyTokenForm = !showDeezyTokenForm"
>Add or Update token</q-btn
>
</div>
<p v-if="storedDeezyToken" v-text="storedDeezyToken"></p>
</div>
<q-form
v-if="showDeezyTokenForm"
@submit="storeDeezyToken"
class="q-gutter-md q-mt-lg"
>
<q-input
filled
dense
emit-value
:placeholder="storedDeezyToken"
v-model.trim="deezyToken"
label="Deezy Token"
type="text"
></q-input>
<q-btn color="grey" type="submit" label="Store Deezy Token"></q-btn>
</q-form>
<q-separator class="q-my-lg"></q-separator>
<q-card>
<q-card-section>
<q-btn
label="SWAP (LIGHTNING -> BTC)"
unelevated
color="primary"
@click="showLnToBtcForm"
:disabled="!storedDeezyToken"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
Send lightning btc and receive on-chain btc
</q-tooltip>
</q-btn>
<q-btn
label="SWAP (BTC -> LIGHTNING)"
unelevated
color="primary"
@click="swapBtcToLn.show = true; swapLnToBtc.show = false;"
:disabled="!storedDeezyToken"
>
<q-tooltip class="bg-grey-8" anchor="bottom left" self="top left">
Send on-chain btc and receive via lightning
</q-tooltip>
</q-btn>
</q-card-section>
</q-card>
<div
v-show="swapLnToBtc.show"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<h6 class="q-mt-none">LIGHTNING BTC -> BTC</h6>
<q-form @submit="sendLnToBtc" class="q-gutter-md">
<q-input
filled
dense
emit-value
v-model.trim="swapLnToBtc.data.amount"
label="Amount (sats)"
type="number"
></q-input>
<q-input
filled
dense
emit-value
v-model.trim="swapLnToBtc.data.on_chain_address"
type="string"
label="Onchain address to receive funds"
></q-input>
<q-input
filled
dense
emit-value
v-model.trim="swapLnToBtc.data.on_chain_sats_per_vbyte"
label="On chain fee rate (sats/vbyte)"
min="1"
type="number"
:hint="swapLnToBtc.suggested_fees && `Economy Fee - ${swapLnToBtc.suggested_fees?.economyFee} | Half an hour fee - ${swapLnToBtc.suggested_fees?.halfHourFee} | Fastest fee - ${swapLnToBtc.suggested_fees?.fastestFee}`"
>
</q-input>
<q-btn
unelevated
color="primary"
type="submit"
label="Create Swap"
></q-btn>
<q-btn flat color="grey" class="q-ml-auto" @click="resetSwapLnToBtc"
>Cancel</q-btn
>
</q-form>
<q-dialog v-model="swapLnToBtc.showInvoice" persistent>
<q-card flat bordered class="my-card">
<q-card-section>
<div class="flex justify-between">
<div class="text-h6">Pay invoice to complete swap</div>
<q-btn flat v-close-popup>
<q-icon name="close" />
</q-btn>
</div>
</q-card-section>
<q-card-section class="q-pt-none">
<qrcode
:value="swapLnToBtc.response"
:options="{width: 360}"
class="rounded-borders"
></qrcode>
</q-card-section>
<q-card-section>
<q-btn
outline
@click="copyLnInvoice"
label="Copy"
color="primary"
></q-btn>
<q-input
v-model="swapLnToBtc.response"
type="textarea"
readonly
@click="$event.target.select()"
/>
</q-card-section>
</q-card>
</q-dialog>
</div>
<div
v-show="swapBtcToLn.show"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<h6 class="q-mt-none">BTC -> LIGHTNING BTC</h6>
<q-form @submit="sendBtcToLn" class="q-gutter-md">
<q-input
filled
dense
emit-value
v-model.trim="swapBtcToLn.data.lnurl_or_lnaddress"
label="Lnurl or Lightning Address"
type="string"
></q-input>
<q-btn
unelevated
color="primary"
type="submit"
label="Generate Onchain Address"
></q-btn>
<q-btn flat color="grey" class="q-ml-auto" @click="resetSwapBtcToLn"
>Cancel</q-btn
>
</q-form>
<q-dialog v-model="swapBtcToLn.showDetails" persistent>
<q-card flat bordered class="my-card">
<q-card-section>
<div class="flex justify-between">
<div class="text-h6">Onchain Address</div>
<q-btn flat v-close-popup>
<q-icon name="close" />
</q-btn>
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="swapBtcToLn.response.address"
type="textarea"
readonly
@click="$event.target.select()"
/>
</q-card-section>
<q-card-section>
<q-btn
outline
@click="copyBtcToLnBtcAddress"
label="Copy Address"
color="primary"
></q-btn>
</q-card-section>
<q-card-section>
<q-input
v-model="swapBtcToLn.response.commitment"
type="textarea"
readonly
@click="$event.target.select()"
/>
</q-card-section>
</q-card>
</q-dialog>
</div>
</q-card-section>
</q-card>
{% raw %}
<q-dialog v-model="swapLnToBtc.invoicePaid">
<q-card class="bg-teal text-white" style="width: 400px">
<q-card-section>
<div class="text-h6">Success Bitcoin is on its way</div>
</q-card-section>
<q-card-section class="q-pt-none">
Onchain tx id {{ swapLnToBtc.onchainTxId }}
</q-card-section>
<q-card-actions align="right" class="bg-white text-teal">
<q-btn flat label="OK" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
{% endraw %}
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Boltz extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "deezy/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<div class="q-pa-md full-width">
<q-table
title="Swaps Lightning -> BTC"
:data="rowsLnToBtc"
:columns="columnsLnToBtc"
row-key="name"
/>
</div>
<div class="q-pa-md full-width">
<q-table
title="Swaps BTC -> Lightning"
:data="rowsBtcToLn"
:columns="columnsBtcToLn"
row-key="name"
/>
</div>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
columnsLnToBtc: [
{
name: 'amount_sats',
label: 'Amount Sats',
align: 'left',
field: 'amount_sats',
sortable: true
},
{
name: 'on_chain_address',
align: 'left',
label: 'On chain address',
field: 'on_chain_address'
},
{
name: 'on_chain_sats_per_vbyte',
align: 'left',
label: 'Onchin sats per vbyte',
field: 'on_chain_sats_per_vbyte',
sortable: true
},
{
name: 'fee_sats',
label: 'Fee sats',
align: 'left',
field: 'fee_sats'
},
{name: 'txid', label: 'Tx Id', align: 'left', field: 'txid'},
{name: 'tx_hex', label: 'Tx Hex', align: 'left', field: 'tx_hex'},
{
name: 'created_at',
label: 'Created at',
align: 'left',
field: 'created_at',
sortable: true,
sort: true
}
],
rowsLnToBtc: [],
columnsBtcToLn: [
{
name: 'ln_address',
align: 'left',
label: 'Ln Address or Invoice',
field: 'ln_address'
},
{
name: 'on_chain_address',
align: 'left',
label: 'Onchain Address',
field: 'on_chain_address'
},
{
name: 'secret_access_key',
align: 'left',
label: 'Secret Access Key',
field: 'secret_access_key'
},
{
name: 'commitment',
align: 'left',
label: 'Commitment',
field: 'commitment'
},
{
name: 'signature',
align: 'left',
label: 'Signature',
field: 'signature'
},
{
name: 'created_at',
label: 'Created at',
field: 'created_at',
align: 'left',
sortable: true,
sort: true
}
],
rowsBtcToLn: [],
showDeezyTokenForm: false,
storedDeezyToken: null,
deezyToken: null,
lightning_btc: '',
tools: [],
swapLnToBtc: {
show: false,
showInvoice: false,
data: {
on_chain_sats_per_vbyte: 1
},
suggested_fees: null,
response: null,
invoicePaid: false,
onchainTxId: null
},
swapBtcToLn: {
show: false,
showDetails: false,
data: {},
response: {}
}
}
},
created: async function () {
this.getToken()
this.getLnToBtc()
this.getBtcToLn()
},
methods: {
updateLnToBtc(payload) {
var self = this
return axios
.post('/deezy/api/v1/update-ln-to-btc', {
...payload
})
.then(function (response) {
console.log('btc to ln is update', response)
})
.catch(function (error) {
console.log(error)
})
},
getToken() {
var self = this
axios({
method: 'GET',
url: '/deezy/api/v1/token'
}).then(function (response) {
self.storedDeezyToken = response.data.deezy_token
if (!self.storeDeezyToken) {
showDeezyTokenForm = true
}
})
},
getLnToBtc() {
var self = this
axios.get('/deezy/api/v1/ln-to-btc').then(function (response) {
if (response.data.length) {
self.rowsLnToBtc = response.data
}
})
},
getBtcToLn() {
var self = this
axios.get('/deezy/api/v1/btc-to-ln').then(function (response) {
if (response.data.length) {
self.rowsBtcToLn = response.data
}
})
},
showLnToBtcForm() {
if (!this.swapLnToBtc.show) {
this.getSuggestedOnChainFees()
}
this.swapLnToBtc.show = true
this.swapBtcToLn.show = false
},
getSuggestedOnChainFees() {
axios
.get('https://mempool.space/api/v1/fees/recommended')
.then(result => {
this.swapLnToBtc.suggested_fees = result.data
})
},
checkIfInvoiceIsPaid() {
if (this.swapLnToBtc.response && !this.swapLnToBtc.invoicePaid) {
var self = this
let interval = setInterval(() => {
axios
.get(
`https://api.deezy.io/v1/swap/lookup?bolt11_invoice=${self.swapLnToBtc.response}`
)
.then(async function (response) {
if (response.data.on_chain_txid) {
self.swapLnToBtc = {
...self.swapLnToBtc,
invoicePaid: true,
onchainTxId: response.data.on_chain_txid
}
self
.updateLnToBtc({
txid: response.data.on_chain_txid,
tx_hex: response.data.tx_hex,
bolt11_invoice: self.swapLnToBtc.response
})
.then(() => {
self.getLnToBtc()
})
clearInterval(interval)
}
})
}, 4000)
}
},
copyLnInvoice() {
Quasar.utils.copyToClipboard(this.swapLnToBtc.response)
},
copyBtcToLnBtcAddress() {
Quasar.utils.copyToClipboard(this.swapBtcToLn.response.address)
},
sendLnToBtc() {
var self = this
axios
.post(
'https://api.deezy.io/v1/swap',
{
amount_sats: parseInt(self.swapLnToBtc.data.amount),
on_chain_address: self.swapLnToBtc.data.on_chain_address,
on_chain_sats_per_vbyte: parseInt(
self.swapLnToBtc.data.on_chain_sats_per_vbyte
)
},
{
headers: {
'x-api-token': self.storedDeezyToken
}
}
)
.then(function (response) {
self.swapLnToBtc = {
...self.swapLnToBtc,
showInvoice: true,
response: response.data.bolt11_invoice
}
const payload = {
amount_sats: parseInt(self.swapLnToBtc.data.amount),
on_chain_address: self.swapLnToBtc.data.on_chain_address,
on_chain_sats_per_vbyte:
self.swapLnToBtc.data.on_chain_sats_per_vbyte,
bolt11_invoice: response.data.bolt11_invoice,
fee_sats: response.data.fee_sats
}
self.storeLnToBtc(payload)
self.checkIfInvoiceIsPaid()
})
.catch(function (error) {
console.log(error)
})
},
sendBtcToLn() {
var self = this
axios
.post(
'https://api.deezy.io/v1/source',
{
lnurl_or_lnaddress: self.swapBtcToLn.data.lnurl_or_lnaddress
},
{
headers: {
'x-api-token': self.storedDeezyToken
}
}
)
.then(function (response) {
self.swapBtcToLn = {
...self.swapBtcToLn,
response: response.data,
showDetails: true
}
const payload = {
ln_address: self.swapBtcToLn.data.lnurl_or_lnaddress,
on_chain_address: response.data.address,
secret_access_key: response.data.secret_access_key,
commitment: response.data.commitment,
signature: response.data.signature
}
self.storeBtcToLn(payload)
})
.catch(function (error) {
console.log(error)
})
},
storeBtcToLn(payload) {
var self = this
axios
.post('/deezy/api/v1/store-btc-to-ln', {
...payload
})
.then(function (response) {
console.log('btc to ln is stored', response)
})
.catch(function (error) {
console.log(error)
})
},
storeLnToBtc(payload) {
var self = this
axios
.post('/deezy/api/v1/store-ln-to-btc', {
...payload
})
.then(function (response) {
console.log('ln to btc is stored', response)
})
.catch(function (error) {
console.log(error)
})
},
storeDeezyToken() {
var self = this
axios
.post('/deezy/api/v1/store-token', {
deezy_token: self.deezyToken
})
.then(function (response) {
self.storedDeezyToken = response.data
self.showDeezyTokenForm = false
})
.catch(function (error) {
console.log(error)
})
},
resetSwapBtcToLn() {
this.swapBtcToLn = {
...this.swapBtcToLn,
data: {}
}
},
resetSwapLnToBtc() {
this.swapLnToBtc = {
...this.swapLnToBtc,
data: {}
}
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,21 @@
from fastapi import FastAPI, Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import deezy_ext, deezy_renderer
templates = Jinja2Templates(directory="templates")
@deezy_ext.get("/", response_class=HTMLResponse)
async def index(
request: Request,
user: User = Depends(check_user_exists), # type: ignore
):
return deezy_renderer().TemplateResponse(
"deezy/index.html", {"request": request, "user": user.dict()}
)

View File

@ -0,0 +1,65 @@
# views_api.py is for you API endpoints that could be hit by another service
# add your dependencies here
# import httpx
# (use httpx just like requests, except instead of response.ok there's only the
# response.is_error that is its inverse)
from . import deezy_ext
from .crud import (
get_btc_to_ln,
get_ln_to_btc,
get_token,
save_btc_to_ln,
save_ln_to_btc,
save_token,
update_ln_to_btc,
)
from .models import BtcToLnSwap, LnToBtcSwap, Token, UpdateLnToBtcSwap
@deezy_ext.get("/api/v1/token")
async def api_deezy_get_token():
rows = await get_token()
return rows
@deezy_ext.get("/api/v1/ln-to-btc")
async def api_deezy_get_ln_to_btc():
rows = await get_ln_to_btc()
return rows
@deezy_ext.get("/api/v1/btc-to-ln")
async def api_deezy_get_btc_to_ln():
rows = await get_btc_to_ln()
return rows
@deezy_ext.post("/api/v1/store-token")
async def api_deezy_save_toke(data: Token):
await save_token(data)
return data.deezy_token
@deezy_ext.post("/api/v1/store-ln-to-btc")
async def api_deezy_save_ln_to_btc(data: LnToBtcSwap):
response = await save_ln_to_btc(data)
return response
@deezy_ext.post("/api/v1/update-ln-to-btc")
async def api_deezy_update_ln_to_btc(data: UpdateLnToBtcSwap):
response = await update_ln_to_btc(data)
return response
@deezy_ext.post("/api/v1/store-btc-to-ln")
async def api_deezy_save_btc_to_ln(data: BtcToLnSwap):
response = await save_btc_to_ln(data)
return response

View File

@ -67,7 +67,7 @@
>
<q-tab name="frameworks" label="Frameworks"></q-tab>
<q-tab name="tools" label="Useful Tools"></q-tab>
<q-tab name="structure" label="File Structure"></q-tab>
<q-tab name="goodpractice" label="Good Practice"></q-tab>
<q-tab name="enviroment" label="Dev Enviroment"></q-tab>
<q-tab name="submission" label="Submisson to LNbits repo"></q-tab>
</q-tabs>
@ -79,200 +79,189 @@
<div class="text-h6">Frameworks</div>
<div>
<q-splitter v-model="splitterModel">
<template v-slot:before>
<q-tabs v-model="framworktab" vertical>
<q-tab name="fastapi"
><img src="./static/fastapi-framework.png" />FASTAPI</q-tab
>
<q-tab name="quasar"
><img src="./static/quasar-framework.png" />QUASAR</q-tab
>
<q-tab name="vuejs"
><img src="./static/vuejs-framework.png" />VUE-JS</q-tab
>
</q-tabs>
</template>
<template>
<q-tabs align="left" v-model="framworktab" inline-label>
<q-tab name="fastapi"
><img src="./static/fastapi-framework.png" />FASTAPI</q-tab
>
<q-tab name="quasar"
><img src="./static/quasar-framework.png" />QUASAR</q-tab
>
<q-tab name="vuejs"
><img src="./static/vuejs-framework.png" />VUE-JS</q-tab
>
</q-tabs>
</template>
<template v-slot:after>
<q-tab-panels v-model="framworktab">
<q-tab-panel name="fastapi" class="text-body1">
<a href="https://fastapi.tiangolo.com/"
><img src="./static/fastapilogo.png"
/></a>
<p>
LNbits API is built using
<a
href="https://fastapi.tiangolo.com/"
class="text-primary"
>FastAPI</a
>, a high-performance, easy to code API framework.<br /><br />
FastAPI auto-generates swagger UI docs for testing
endpoints <a class="text-primary" href="../docs">/docs</a>
</p>
<template>
<q-tab-panels v-model="framworktab">
<q-tab-panel name="fastapi" class="text-body1">
<a href="https://fastapi.tiangolo.com/"
><img src="./static/fastapilogo.png"
/></a>
<p>
LNbits API is built using
<a href="https://fastapi.tiangolo.com/" class="text-primary"
>FastAPI</a
>, a high-performance, easy to code API framework.<br /><br />
FastAPI auto-generates swagger UI docs for testing endpoints
<a class="text-primary" href="../docs">/docs</a>
</p>
<i>
<strong>TIP:</strong> Although it is possible for
extensions to use other extensions API endpoints (such as
with the Satspay and Onchain extension), ideally an
extension should only use LNbits
<a href="../docs#/default" class="text-primary">core</a>
endpoints. </i
><br /><br />
<i>
<strong>TIP:</strong> Although it is possible for extensions
to use other extensions API endpoints (such as with the
Satspay and Onchain extension), ideally an extension should
only use LNbits
<a href="../docs#/default" class="text-primary">core</a>
endpoints. </i
><br /><br />
<code class="bg-grey-3 text-black">views.py</code> is used
for setting application routes:
<img src="./static/fastapi-example.png" />
<code class="bg-grey-3 text-black">views_api.py</code> is
used for setting application API endpoints:
<img src="./static/fastapi-example2.png" />
</q-tab-panel>
<code class="bg-grey-3 text-black">views.py</code> is used for
setting application routes:
<img src="./static/fastapi-example.png" /><br /><br />
<code class="bg-grey-3 text-black">views_api.py</code> is used
for setting application API endpoints:<br />
<img src="./static/fastapi-example2.png" />
</q-tab-panel>
<q-tab-panel name="quasar" class="text-body1">
<a href="https://quasar.dev/"
><img src="./static/quasarlogo.png"
/></a>
<p>
LNbits uses
<a class="text-primary" href="https://quasar.dev/"
>Quasar Framework</a
>
for frontend deisgn elements. Quasar Framework is an
open-source Vue.js based framework for building apps.
</p>
<i>
<strong>TIP:</strong> Look through
<code class="bg-grey-3 text-black">/template</code> files
in other extensions for examples of Quasar elements being
used. </i
><br /><br />
<p>
In the below example we make a dialogue popup box (box can
be triggered
<q-btn
size="sm"
color="primary"
@click="thingDialog.show = true"
>here</q-btn
>): <q-tooltip>Exmple of a tooltip!</q-tooltip>
</p>
<img src="./static/quasar-example.png" /><br /><br />
<div class="text-h6">Useful links:</div>
<q-btn
color="primary"
type="a"
href="https://quasar.dev/style/"
>Style (typography, spacing, etc)</q-btn
<q-tab-panel name="quasar" class="text-body1">
<a href="https://quasar.dev/"
><img src="./static/quasarlogo.png"
/></a>
<p>
LNbits uses
<a class="text-primary" href="https://quasar.dev/"
>Quasar Framework</a
>
<q-btn
color="primary"
type="a"
href="https://quasar.dev/vue-components/"
>Genral components (cards, buttons, popup dialogs,
etc)</q-btn
>
<q-btn
color="primary"
type="a"
href="https://quasar.dev/layout/grid"
>Layouts (rows/columns, flexbox)</q-btn
>
</q-tab-panel>
for frontend deisgn elements. Quasar Framework is an
open-source Vue.js based framework for building apps.
</p>
<q-tab-panel
v-if="someBool == true"
name="vuejs"
class="text-body1"
<i>
<strong>TIP:</strong> Look through
<code class="bg-grey-3 text-black">/template</code> files in
other extensions for examples of Quasar elements being used. </i
><br /><br />
<p>
In the below example we make a dialogue popup box (box can
be triggered
<q-btn
size="sm"
color="primary"
@click="thingDialog.show = true"
>here</q-btn
>): <q-tooltip>Exmple of a tooltip!</q-tooltip>
</p>
<img src="./static/quasar-example.png" /><br /><br />
<div class="text-h6">Useful links:</div>
<q-btn
color="primary"
type="a"
href="https://quasar.dev/style/"
>Style (typography, spacing, etc)</q-btn
>
<a href="https://vuejs.org/">
<img src="./static/vuejslogo.png"
/></a>
<p>
LNbits uses
<a href="https://vuejs.org/" class="text-primary">Vue</a>
components for best-in-class high-performance and
responsive performance.
</p>
<q-btn
color="primary"
type="a"
href="https://quasar.dev/vue-components/"
>Genral components (cards, buttons, popup dialogs,
etc)</q-btn
>
<q-btn
color="primary"
type="a"
href="https://quasar.dev/layout/grid"
>Layouts (rows/columns, flexbox)</q-btn
>
</q-tab-panel>
<p>
Typical example of Vue components in a frontend script:
</p>
<img
src="./static/script-example.png"
style="max-width: 800px"
/><br /><br />
<q-tab-panel
v-if="someBool == true"
name="vuejs"
class="text-body1"
>
<a href="https://vuejs.org/">
<img src="./static/vuejslogo.png"
/></a>
<p>
LNbits uses
<a href="https://vuejs.org/" class="text-primary">Vue</a>
components for best-in-class high-performance and responsive
performance.
</p>
<p>
In a page body, models can be called. <br />Content can be
conditionally rendered using Vue's
<code class="bg-grey-3 text-black">v-if</code>:
</p>
<img
src="./static/vif-example.png"
style="max-width: 800px"
/>
</q-tab-panel>
</q-tab-panels>
</template>
</q-splitter>
<p>Typical example of Vue components in a frontend script:</p>
<img
src="./static/script-example.png"
style="max-width: 800px"
/><br /><br />
<p>
In a page body, models can be called. <br />Content can be
conditionally rendered using Vue's
<code class="bg-grey-3 text-black">v-if</code>:
</p>
<img
src="./static/vif-example.png"
style="max-width: 800px"
/>
</q-tab-panel>
</q-tab-panels>
</template>
</div>
</q-tab-panel>
<q-tab-panel name="tools">
<div class="text-h6">Useful Tools</div>
<div>
<q-splitter v-model="splitterModel">
<template v-slot:before>
<q-tabs v-model="usefultab" vertical>
<q-tab name="magicalg">MAGICAL G</q-tab>
<q-tab name="exchange">EXCHANGE RATES</q-tab>
</q-tabs>
</template>
<template>
<q-tabs v-model="usefultab" align="left">
<q-tab name="magicalg">MAGICAL G</q-tab>
<q-tab name="exchange">EXCHANGE RATES</q-tab>
</q-tabs>
</template>
<template v-slot:after>
<q-tab-panels v-model="usefultab">
<q-tab-panel name="magicalg" class="text-body1">
<div class="text-h5 q-mb-md">Magical G</div>
<p>
A magical "g" (ie
<code class="bg-grey-3 text-black"
>this.g.user.wallets[0].inkey</code
>) is always available, with info about the user, wallets
and extensions:
</p>
<code class="text-caption"
>{% raw %}{{ g }}{% endraw %}</code
>
</q-tab-panel>
<q-tab-panel name="exchange">
<div class="text-h6">Exchange rates</div>
<p>
LNbits includes a handy
<a
href="../docs#/default/api_fiat_as_sats_api_v1_conversion_post"
class="text-primary"
>exchange rate function</a
>, that streams rates from 6 different sources.
</p>
Exchange rate API:<br />
<img src="./static/conversion-example.png" /><br /><br />
Exchange rate functions, included using
<template>
<q-tab-panels v-model="usefultab">
<q-tab-panel name="magicalg" class="text-body1">
<div class="text-h5 q-mb-md">Magical G</div>
<p>
A magical "g" (ie
<code class="bg-grey-3 text-black"
>from lnbits.utils.exchange_rates import
fiat_amount_as_satoshis</code
>:<br />
<img src="./static/conversion-example2.png" />
</q-tab-panel>
</q-tab-panels>
</template>
</q-splitter>
>this.g.user.wallets[0].inkey</code
>) is always available, with info about the user, wallets
and extensions:
</p>
<code class="text-caption">{% raw %}{{ g }}{% endraw %}</code>
</q-tab-panel>
<q-tab-panel name="exchange">
<div class="text-h6">Exchange rates</div>
<p>
LNbits includes a handy
<a
href="../docs#/default/api_fiat_as_sats_api_v1_conversion_post"
class="text-primary"
>exchange rate function</a
>, that streams rates from 6 different sources.
</p>
Exchange rate API:<br />
<img src="./static/conversion-example.png" /><br /><br />
Exchange rate functions, included using
<code class="bg-grey-3 text-black"
>from lnbits.utils.exchange_rates import
fiat_amount_as_satoshis</code
>:<br />
<img src="./static/conversion-example2.png" />
</q-tab-panel>
</q-tab-panels>
</template>
</div>
</q-tab-panel>
<q-tab-panel name="structure">
<div class="text-h6">File Structure</div>
<q-tab-panel name="goodpractice">
<div class="text-h6">Good Practice</div>
Coming soon...
</q-tab-panel>

View File

@ -1,12 +1,9 @@
import hashlib
import math
from http import HTTPStatus
from os import name
from fastapi.exceptions import HTTPException
from fastapi.params import Query
from fastapi import HTTPException, Query, Request
from lnurl import LnurlErrorResponse, LnurlPayActionResponse, LnurlPayResponse
from starlette.requests import Request # type: ignore
from lnurl.models import ClearnetUrl, LightningInvoice, MilliSatoshi
from lnbits.core.services import create_invoice
@ -29,9 +26,12 @@ async def lnurl_livestream(ls_id, request: Request):
)
resp = LnurlPayResponse(
callback=request.url_for("livestream.lnurl_callback", track_id=track.id),
min_sendable=track.min_sendable,
max_sendable=track.max_sendable,
callback=ClearnetUrl(
request.url_for("livestream.lnurl_callback", track_id=track.id),
scheme="https",
),
minSendable=MilliSatoshi(track.min_sendable),
maxSendable=MilliSatoshi(track.max_sendable),
metadata=await track.lnurlpay_metadata(),
)
@ -48,9 +48,12 @@ async def lnurl_track(track_id, request: Request):
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Track not found.")
resp = LnurlPayResponse(
callback=request.url_for("livestream.lnurl_callback", track_id=track.id),
min_sendable=track.min_sendable,
max_sendable=track.max_sendable,
callback=ClearnetUrl(
request.url_for("livestream.lnurl_callback", track_id=track.id),
scheme="https",
),
minSendable=MilliSatoshi(track.min_sendable),
maxSendable=MilliSatoshi(track.max_sendable),
metadata=await track.lnurlpay_metadata(),
)
@ -85,6 +88,7 @@ async def lnurl_callback(
).dict()
ls = await get_livestream_by_track(track_id)
assert ls
extra_amount = amount_received - int(amount_received * (100 - ls.fee_pct) / 100)
@ -101,13 +105,14 @@ async def lnurl_callback(
},
)
assert track.price_msat
if amount_received < track.price_msat:
success_action = None
else:
success_action = track.success_action(payment_hash, request=request)
resp = LnurlPayActionResponse(
pr=payment_request, success_action=success_action, routes=[]
pr=LightningInvoice(payment_request), successAction=success_action, routes=[]
)
return resp.dict()

View File

@ -1,13 +1,12 @@
import json
from typing import Optional
from fastapi import Query
from fastapi import Query, 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 lnurl import encode as lnurl_encode
from lnurl.models import ClearnetUrl, Max144Str, UrlAction
from lnurl.types import LnurlPayMetadata
from pydantic import BaseModel
from starlette.requests import Request
class CreateTrack(BaseModel):
@ -32,7 +31,7 @@ class Livestream(BaseModel):
class Track(BaseModel):
id: int
download_url: Optional[str]
price_msat: Optional[int]
price_msat: int = 0
name: str
producer: int
@ -71,7 +70,7 @@ class Track(BaseModel):
def success_action(
self, payment_hash: str, request: Request
) -> Optional[LnurlPaySuccessAction]:
) -> Optional[UrlAction]:
if not self.download_url:
return None
@ -79,7 +78,8 @@ class Track(BaseModel):
url_with_query = f"{url}?p={payment_hash}"
return UrlAction(
url=url_with_query, description=f"Download the track {self.name}!"
url=ClearnetUrl(url_with_query, scheme="https"),
description=Max144Str(f"Download the track {self.name}!"),
)

View File

@ -1,20 +1,16 @@
from http import HTTPStatus
from fastapi.param_functions import Depends
from fastapi.params import Query
from starlette.exceptions import HTTPException
from starlette.requests import Request
from fastapi import Depends, HTTPException, Query, Request
from starlette.datastructures import URL
from starlette.responses import HTMLResponse, RedirectResponse
from lnbits.core.crud import get_wallet_payment
from lnbits.core.models import Payment, User
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import livestream_ext, livestream_renderer
from .crud import get_livestream_by_track, get_track
# from mmap import MAP_DENYWRITE
@livestream_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
@ -28,12 +24,18 @@ async def track_redirect_download(track_id, p: str = Query(...)):
payment_hash = p
track = await get_track(track_id)
ls = await get_livestream_by_track(track_id)
payment: Payment = await get_wallet_payment(ls.wallet, payment_hash)
assert ls
payment = await get_wallet_payment(ls.wallet, payment_hash)
if not payment:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Couldn't find the payment {payment_hash} or track {track.id}.",
detail=f"Couldn't find the payment {payment_hash}.",
)
if not track:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail=f"Couldn't find the track {track_id}.",
)
if payment.pending:
@ -41,4 +43,6 @@ async def track_redirect_download(track_id, p: str = Query(...)):
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Payment {payment_hash} wasn't received yet. Please try again in a minute.",
)
return RedirectResponse(url=track.download_url)
assert track.download_url
return RedirectResponse(url=URL(track.download_url))

View File

@ -1,9 +1,7 @@
from http import HTTPStatus
from fastapi.param_functions import Depends
from fastapi import Depends, HTTPException, Request
from lnurl.exceptions import InvalidUrl as LnurlInvalidUrl
from starlette.exceptions import HTTPException
from starlette.requests import Request # type: ignore
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.livestream.models import CreateTrack
@ -27,6 +25,7 @@ async def api_livestream_from_wallet(
req: Request, g: WalletTypeInfo = Depends(get_key_type)
):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
assert ls
tracks = await get_tracks(ls.id)
producers = await get_producers(ls.id)
@ -55,17 +54,17 @@ async def api_update_track(track_id, g: WalletTypeInfo = Depends(get_key_type)):
id = int(track_id)
except ValueError:
id = 0
if id <= 0:
id = None
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
await update_current_track(ls.id, id)
assert ls
await update_current_track(ls.id, None if id <= 0 else id)
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)
assert ls
await update_livestream_fee(ls.id, int(fee_pct))
return "", HTTPStatus.NO_CONTENT
@ -76,9 +75,10 @@ async def api_add_track(
data: CreateTrack, id=None, g: WalletTypeInfo = Depends(get_key_type)
):
ls = await get_or_create_livestream_by_wallet(g.wallet.id)
assert ls
if data.producer_id:
p_id = data.producer_id
p_id = int(data.producer_id)
elif data.producer_name:
p_id = await add_producer(ls.id, data.producer_name)
else:
@ -96,5 +96,6 @@ async def api_add_track(
@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)
assert ls
await delete_track_from_livestream(ls.id, track_id)
return "", HTTPStatus.NO_CONTENT

View File

@ -7,8 +7,6 @@ from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import createLnurldevice, lnurldevicepayment, lnurldevices
###############lnurldeviceS##########################
async def create_lnurldevice(
data: createLnurldevice,
@ -69,10 +67,12 @@ async def create_lnurldevice(
data.pin4,
),
)
return await get_lnurldevice(lnurldevice_id)
device = await get_lnurldevice(lnurldevice_id)
assert device
return device
async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldevices]:
async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> lnurldevices:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE lnurldevice.lnurldevices SET {q} WHERE id = ?",
@ -81,19 +81,18 @@ async def update_lnurldevice(lnurldevice_id: str, **kwargs) -> Optional[lnurldev
row = await db.fetchone(
"SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)
)
return lnurldevices(**row) if row else None
return lnurldevices(**row)
async def get_lnurldevice(lnurldevice_id: str) -> lnurldevices:
async def get_lnurldevice(lnurldevice_id: str) -> Optional[lnurldevices]:
row = await db.fetchone(
"SELECT * FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)
)
return lnurldevices(**row) if row else None
async def get_lnurldevices(wallet_ids: Union[str, List[str]]) -> List[lnurldevices]:
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids[0]))
async def get_lnurldevices(wallet_ids: List[str]) -> List[lnurldevices]:
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"""
SELECT * FROM lnurldevice.lnurldevices WHERE wallet IN ({q})
@ -102,7 +101,7 @@ async def get_lnurldevices(wallet_ids: Union[str, List[str]]) -> List[lnurldevic
(*wallet_ids,),
)
return [lnurldevices(**row) if row else None for row in rows]
return [lnurldevices(**row) for row in rows]
async def delete_lnurldevice(lnurldevice_id: str) -> None:
@ -110,8 +109,6 @@ async def delete_lnurldevice(lnurldevice_id: str) -> None:
"DELETE FROM lnurldevice.lnurldevices WHERE id = ?", (lnurldevice_id,)
)
########################lnuldevice payments###########################
async def create_lnurldevicepayment(
deviceid: str,
@ -121,6 +118,7 @@ async def create_lnurldevicepayment(
sats: Optional[int] = 0,
) -> lnurldevicepayment:
device = await get_lnurldevice(deviceid)
assert device
if device.device == "atm":
lnurldevicepayment_id = shortuuid.uuid(name=payload)
else:
@ -139,7 +137,9 @@ async def create_lnurldevicepayment(
""",
(lnurldevicepayment_id, deviceid, payload, pin, payhash, sats),
)
return await get_lnurldevicepayment(lnurldevicepayment_id)
dpayment = await get_lnurldevicepayment(lnurldevicepayment_id)
assert dpayment
return dpayment
async def update_lnurldevicepayment(
@ -157,7 +157,9 @@ async def update_lnurldevicepayment(
return lnurldevicepayment(**row) if row else None
async def get_lnurldevicepayment(lnurldevicepayment_id: str) -> lnurldevicepayment:
async def get_lnurldevicepayment(
lnurldevicepayment_id: str,
) -> Optional[lnurldevicepayment]:
row = await db.fetchone(
"SELECT * FROM lnurldevice.lnurldevicepayment WHERE id = ?",
(lnurldevicepayment_id,),
@ -165,7 +167,9 @@ async def get_lnurldevicepayment(lnurldevicepayment_id: str) -> lnurldevicepayme
return lnurldevicepayment(**row) if row else None
async def get_lnurlpayload(lnurldevicepayment_payload: str) -> lnurldevicepayment:
async def get_lnurlpayload(
lnurldevicepayment_payload: str,
) -> Optional[lnurldevicepayment]:
row = await db.fetchone(
"SELECT * FROM lnurldevice.lnurldevicepayment WHERE payload = ?",
(lnurldevicepayment_payload,),

View File

@ -1,16 +1,11 @@
import base64
import hashlib
import hmac
from http import HTTPStatus
from io import BytesIO
from typing import Optional
import shortuuid
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 fastapi import HTTPException, Query, Request
from lnbits import bolt11
from lnbits.core.services import create_invoice
@ -44,7 +39,9 @@ def bech32_decode(bech):
encoding = bech32.bech32_verify_checksum(hrp, data)
if encoding is None:
return
return bytes(bech32.convertbits(data[:-6], 5, 8, False))
bits = bech32.convertbits(data[:-6], 5, 8, False)
assert bits
return bytes(bits)
def xor_decrypt(key, blob):
@ -105,6 +102,8 @@ async def lnurl_v1_params(
"reason": f"lnurldevice {device_id} not found on this server",
}
if device.device == "switch":
# TODO: AMOUNT IN CENT was never reference here
amount_in_cent = 0
price_msat = (
await fiat_amount_as_satoshis(float(profit), device.currency)
if device.currency != "sat"
@ -160,23 +159,18 @@ async def lnurl_v1_params(
if device.device != "atm":
return {"status": "ERROR", "reason": "Not ATM device."}
price_msat = int(price_msat * (1 - (device.profit / 100)) / 1000)
lnurldevicepayment = await get_lnurldevicepayment(shortuuid.uuid(name=p))
if lnurldevicepayment:
logger.debug("lnurldevicepayment")
logger.debug(lnurldevicepayment)
logger.debug("lnurldevicepayment")
if lnurldevicepayment.payload == lnurldevicepayment.payhash:
return {"status": "ERROR", "reason": f"Payment already claimed"}
else:
try:
lnurldevicepayment = await create_lnurldevicepayment(
deviceid=device.id,
payload=p,
sats=price_msat * 1000,
pin=pin,
pin=str(pin),
payhash="payment_hash",
)
except:
return {"status": "ERROR", "reason": "Could not create ATM payment."}
if not lnurldevicepayment:
return {"status": "ERROR", "reason": "Could not create payment."}
return {"status": "ERROR", "reason": "Could not create ATM payment."}
return {
"tag": "withdrawRequest",
"callback": request.url_for(
@ -193,7 +187,7 @@ async def lnurl_v1_params(
deviceid=device.id,
payload=p,
sats=price_msat * 1000,
pin=pin,
pin=str(pin),
payhash="payment_hash",
)
if not lnurldevicepayment:
@ -221,6 +215,10 @@ async def lnurl_callback(
k1: str = Query(None),
):
lnurldevicepayment = await get_lnurldevicepayment(paymentid)
if not lnurldevicepayment:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="lnurldevicepayment not found."
)
device = await get_lnurldevice(lnurldevicepayment.deviceid)
if not device:
raise HTTPException(
@ -241,13 +239,17 @@ async def lnurl_callback(
else:
if lnurldevicepayment.payload != k1:
return {"status": "ERROR", "reason": "Bad K1"}
lnurldevicepayment = await update_lnurldevicepayment(
if lnurldevicepayment.payhash != "payment_hash":
return {"status": "ERROR", "reason": f"Payment already claimed"}
lnurldevicepayment_updated = await update_lnurldevicepayment(
lnurldevicepayment_id=paymentid, payhash=lnurldevicepayment.payload
)
assert lnurldevicepayment_updated
await pay_invoice(
wallet_id=device.wallet,
payment_request=pr,
max_sat=lnurldevicepayment.sats / 1000,
max_sat=int(lnurldevicepayment_updated.sats / 1000),
extra={"tag": "withdraw"},
)
return {"status": "OK"}

View File

@ -3,13 +3,9 @@ from sqlite3 import Row
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 lnurl import encode as lnurl_encode
from lnurl.types import LnurlPayMetadata
from pydantic import BaseModel
from pydantic.main import BaseModel
class createLnurldevice(BaseModel):
@ -58,6 +54,7 @@ class lnurldevices(BaseModel):
pin4: int
timestamp: str
@classmethod
def from_row(cls, row: Row) -> "lnurldevices":
return cls(**dict(row))

View File

@ -1,18 +1,11 @@
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.core.services import 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
from .crud import get_lnurldevicepayment, update_lnurldevicepayment
async def wait_for_paid_invoices():
@ -27,14 +20,15 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
# (avoid loops)
if "Switch" == payment.extra.get("tag"):
lnurldevicepayment = await get_lnurldevicepayment(payment.extra.get("id"))
lnurldevicepayment = await get_lnurldevicepayment(payment.extra["id"])
if not lnurldevicepayment:
return
if lnurldevicepayment.payhash == "used":
return
lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
lnurldevicepayment_id=payment.extra["id"], payhash="used"
)
assert lnurldevicepayment
return await websocketUpdater(
lnurldevicepayment.deviceid,
str(lnurldevicepayment.pin) + "-" + str(lnurldevicepayment.payload),

View File

@ -1,12 +1,7 @@
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 import Depends, HTTPException, Query, Request
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse, StreamingResponse
from lnbits.core.crud import update_payment_status
@ -62,4 +57,6 @@ async def img(request: Request, lnurldevice_id):
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
)
return lnurldevice.lnurl(request)
# error: "lnurldevices" has no attribute "lnurl"
# return lnurldevice.lnurl(request)
return None

View File

@ -1,9 +1,6 @@
from http import HTTPStatus
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.params import Depends
from starlette.exceptions import HTTPException
from fastapi import Depends, HTTPException, Query, Request
from lnbits.core.crud import get_user
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
@ -26,9 +23,6 @@ async def api_list_currencies_available():
return list(currencies.keys())
#######################lnurldevice##########################
@lnurldevice_ext.post("/api/v1/lnurlpos")
@lnurldevice_ext.put("/api/v1/lnurlpos/{lnurldevice_id}")
async def api_lnurldevice_create_or_update(
@ -41,7 +35,7 @@ async def api_lnurldevice_create_or_update(
lnurldevice = await create_lnurldevice(data)
return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
else:
lnurldevice = await update_lnurldevice(data, lnurldevice_id=lnurldevice_id)
lnurldevice = await update_lnurldevice(lnurldevice_id, **data.dict())
return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
@ -49,7 +43,8 @@ async def api_lnurldevice_create_or_update(
async def api_lnurldevices_retrieve(
req: Request, wallet: WalletTypeInfo = Depends(get_key_type)
):
wallet_ids = (await get_user(wallet.wallet.user)).wallet_ids
user = await get_user(wallet.wallet.user)
wallet_ids = user.wallet_ids if user else []
try:
return [
{**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
@ -65,10 +60,11 @@ async def api_lnurldevices_retrieve(
return ""
@lnurldevice_ext.get("/api/v1/lnurlpos/{lnurldevice_id}")
@lnurldevice_ext.get(
"/api/v1/lnurlpos/{lnurldevice_id}", dependencies=[Depends(get_key_type)]
)
async def api_lnurldevice_retrieve(
req: Request,
wallet: WalletTypeInfo = Depends(get_key_type),
lnurldevice_id: str = Query(None),
):
lnurldevice = await get_lnurldevice(lnurldevice_id)
@ -76,23 +72,18 @@ async def api_lnurldevice_retrieve(
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="lnurldevice does not exist"
)
if not lnurldevice.lnurl_toggle:
return {**lnurldevice.dict()}
return {**lnurldevice.dict(), **{"switches": lnurldevice.switches(req)}}
@lnurldevice_ext.delete("/api/v1/lnurlpos/{lnurldevice_id}")
async def api_lnurldevice_delete(
wallet: WalletTypeInfo = Depends(require_admin_key),
lnurldevice_id: str = Query(None),
):
@lnurldevice_ext.delete(
"/api/v1/lnurlpos/{lnurldevice_id}", dependencies=[Depends(require_admin_key)]
)
async def api_lnurldevice_delete(lnurldevice_id: str = Query(None)):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Wallet link does not exist."
)
await delete_lnurldevice(lnurldevice_id)
return "", HTTPStatus.NO_CONTENT

View File

@ -41,4 +41,19 @@ location /.well-known/nostr.json {
proxy_cache_valid 200 300s;
proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504;
}
```
Example Caddy configuration
```
my.lnbits.instance {
reverse_proxy {your_lnbits}
}
nip.5.domain {
route /.well-known/nostr.json {
rewrite * /nostrnip5/api/v1/domain/{domain_id}/nostr.json
reverse_proxy {your_lnbits}
}
}
```

View File

@ -201,7 +201,7 @@
dense
v-model.trim="formDialog.data.amount"
label="Amount"
placeholder="10.00"
placeholder="How much do you want to charge?"
></q-input>
<q-input
filled
@ -293,6 +293,7 @@
domains: [],
addresses: [],
currencyOptions: [
'Satoshis',
'USD',
'EUR',
'GBP',

View File

@ -196,7 +196,12 @@ async def api_address_create(
)
address = await create_address_internal(domain_id=domain_id, data=post_data)
price_in_sats = await fiat_amount_as_satoshis(domain.amount / 100, domain.currency)
if domain.currency == "Satoshis":
price_in_sats = domain.amount
else:
price_in_sats = await fiat_amount_as_satoshis(
domain.amount / 100, domain.currency
)
try:
payment_hash, payment_request = await create_invoice(

View File

@ -0,0 +1,14 @@
<h1>SMTP Extension</h1>
This extension allows you to setup a smtp, to offer sending emails with it for a small fee.
## Requirements
- SMTP Server
## Usage
1. Create new emailaddress
2. Verify if email goes to your testemail. Testmail is sent on create and update
3. Share the link with the email form.

View File

@ -0,0 +1,34 @@
import asyncio
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_smtp")
smtp_static_files = [
{
"path": "/smtp/static",
"app": StaticFiles(directory="lnbits/extensions/smtp/static"),
"name": "smtp_static",
}
]
smtp_ext: APIRouter = APIRouter(prefix="/smtp", tags=["smtp"])
def smtp_renderer():
return template_renderer(["lnbits/extensions/smtp/templates"])
from .tasks import wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def smtp_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -0,0 +1,6 @@
{
"name": "SMTP",
"short_description": "Charge sats for sending emails",
"tile": "/smtp/static/smtp-bitcoin-email.png",
"contributors": ["dni"]
}

View File

@ -0,0 +1,168 @@
from http import HTTPStatus
from typing import List, Optional, Union
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import CreateEmail, CreateEmailaddress, Emailaddresses, Emails
from .smtp import send_mail
def get_test_mail(email, testemail):
return CreateEmail(
emailaddress_id=email,
subject="LNBits SMTP - Test Email",
message="This is a test email from the LNBits SMTP extension! email is working!",
receiver=testemail,
)
async def create_emailaddress(data: CreateEmailaddress) -> Emailaddresses:
emailaddress_id = urlsafe_short_hash()
# send test mail for checking connection
email = get_test_mail(data.email, data.testemail)
await send_mail(data, email)
await db.execute(
"""
INSERT INTO smtp.emailaddress (id, wallet, email, testemail, smtp_server, smtp_user, smtp_password, smtp_port, anonymize, description, cost)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
emailaddress_id,
data.wallet,
data.email,
data.testemail,
data.smtp_server,
data.smtp_user,
data.smtp_password,
data.smtp_port,
data.anonymize,
data.description,
data.cost,
),
)
new_emailaddress = await get_emailaddress(emailaddress_id)
assert new_emailaddress, "Newly created emailaddress couldn't be retrieved"
return new_emailaddress
async def update_emailaddress(emailaddress_id: str, **kwargs) -> Emailaddresses:
q = ", ".join([f"{field[0]} = ?" for field in kwargs.items()])
await db.execute(
f"UPDATE smtp.emailaddress SET {q} WHERE id = ?",
(*kwargs.values(), emailaddress_id),
)
row = await db.fetchone(
"SELECT * FROM smtp.emailaddress WHERE id = ?", (emailaddress_id,)
)
# send test mail for checking connection
email = get_test_mail(row.email, row.testemail)
await send_mail(row, email)
assert row, "Newly updated emailaddress couldn't be retrieved"
return Emailaddresses(**row)
async def get_emailaddress(emailaddress_id: str) -> Optional[Emailaddresses]:
row = await db.fetchone(
"SELECT * FROM smtp.emailaddress WHERE id = ?", (emailaddress_id,)
)
return Emailaddresses(**row) if row else None
async def get_emailaddress_by_email(email: str) -> Optional[Emailaddresses]:
row = await db.fetchone("SELECT * FROM smtp.emailaddress WHERE email = ?", (email,))
return Emailaddresses(**row) if row else None
# async def get_emailAddressByEmail(email: str) -> Optional[Emails]:
# row = await db.fetchone(
# "SELECT s.*, d.emailaddress as emailaddress FROM smtp.email s INNER JOIN smtp.emailaddress d ON (s.emailaddress_id = d.id) WHERE s.emailaddress = ?",
# (email,),
# )
# return Subdomains(**row) if row else None
async def get_emailaddresses(wallet_ids: Union[str, List[str]]) -> List[Emailaddresses]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM smtp.emailaddress WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Emailaddresses(**row) for row in rows]
async def delete_emailaddress(emailaddress_id: str) -> None:
await db.execute("DELETE FROM smtp.emailaddress WHERE id = ?", (emailaddress_id,))
## create emails
async def create_email(payment_hash, wallet, data: CreateEmail) -> Emails:
await db.execute(
"""
INSERT INTO smtp.email (id, wallet, emailaddress_id, subject, receiver, message, paid)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
payment_hash,
wallet,
data.emailaddress_id,
data.subject,
data.receiver,
data.message,
False,
),
)
new_email = await get_email(payment_hash)
assert new_email, "Newly created email couldn't be retrieved"
return new_email
async def set_email_paid(payment_hash: str) -> Emails:
email = await get_email(payment_hash)
if email and email.paid == False:
await db.execute(
"""
UPDATE smtp.email
SET paid = true
WHERE id = ?
""",
(payment_hash,),
)
new_email = await get_email(payment_hash)
assert new_email, "Newly paid email couldn't be retrieved"
return new_email
async def get_email(email_id: str) -> Optional[Emails]:
row = await db.fetchone(
"SELECT s.*, d.email as emailaddress FROM smtp.email s INNER JOIN smtp.emailaddress d ON (s.emailaddress_id = d.id) WHERE s.id = ?",
(email_id,),
)
return Emails(**row) if row else None
async def get_emails(wallet_ids: Union[str, List[str]]) -> List[Emails]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT s.*, d.email as emailaddress FROM smtp.email s INNER JOIN smtp.emailaddress d ON (s.emailaddress_id = d.id) WHERE s.wallet IN ({q})",
(*wallet_ids,),
)
return [Emails(**row) for row in rows]
async def delete_email(email_id: str) -> None:
await db.execute("DELETE FROM smtp.email WHERE id = ?", (email_id,))

View File

@ -0,0 +1,35 @@
async def m001_initial(db):
await db.execute(
f"""
CREATE TABLE smtp.emailaddress (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
email TEXT NOT NULL,
testemail TEXT NOT NULL,
smtp_server TEXT NOT NULL,
smtp_user TEXT NOT NULL,
smtp_password TEXT NOT NULL,
smtp_port TEXT NOT NULL,
anonymize BOOLEAN NOT NULL,
description TEXT NOT NULL,
cost INTEGER NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)
await db.execute(
f"""
CREATE TABLE smtp.email (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
emailaddress_id TEXT NOT NULL,
subject TEXT NOT NULL,
receiver TEXT NOT NULL,
message TEXT NOT NULL,
paid BOOLEAN NOT NULL,
time TIMESTAMP NOT NULL DEFAULT {db.timestamp_now}
);
"""
)

View File

@ -0,0 +1,47 @@
from fastapi import Query
from pydantic import BaseModel
class CreateEmailaddress(BaseModel):
wallet: str = Query(...)
email: str = Query(...)
testemail: str = Query(...)
smtp_server: str = Query(...)
smtp_user: str = Query(...)
smtp_password: str = Query(...)
smtp_port: str = Query(...)
description: str = Query(...)
anonymize: bool
cost: int = Query(..., ge=0)
class Emailaddresses(BaseModel):
id: str
wallet: str
email: str
testemail: str
smtp_server: str
smtp_user: str
smtp_password: str
smtp_port: str
anonymize: bool
description: str
cost: int
class CreateEmail(BaseModel):
emailaddress_id: str = Query(...)
subject: str = Query(...)
receiver: str = Query(...)
message: str = Query(...)
class Emails(BaseModel):
id: str
wallet: str
emailaddress_id: str
subject: str
receiver: str
message: str
paid: bool
time: int

View File

@ -0,0 +1,86 @@
import re
import socket
import time
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
from http import HTTPStatus
from smtplib import SMTP_SSL as SMTP
from loguru import logger
from starlette.exceptions import HTTPException
def valid_email(s):
# https://regexr.com/2rhq7
pat = "[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
if re.match(pat, s):
return True
msg = f"SMTP - invalid email: {s}."
logger.error(msg)
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg)
async def send_mail(emailaddress, email):
valid_email(emailaddress.email)
valid_email(email.receiver)
ts = time.time()
date = formatdate(ts, True)
msg = MIMEMultipart("alternative")
msg = MIMEMultipart("alternative")
msg["Date"] = date
msg["Subject"] = email.subject
msg["From"] = emailaddress.email
msg["To"] = email.receiver
signature = "Email sent anonymiously by LNbits Sendmail extension."
text = f"""
{email.message}
{signature}
"""
html = f"""
<html>
<head></head>
<body>
<p>{email.message}<p>
<br>
<p>{signature}</p>
</body>
</html>
"""
part1 = MIMEText(text, "plain")
part2 = MIMEText(html, "html")
msg.attach(part1)
msg.attach(part2)
try:
conn = SMTP(
host=emailaddress.smtp_server, port=emailaddress.smtp_port, timeout=10
)
logger.debug("SMTP - connected to smtp server.")
# conn.set_debuglevel(True)
except:
msg = f"SMTP - error connecting to smtp server: {emailaddress.smtp_server}:{emailaddress.smtp_port}."
logger.error(msg)
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg)
try:
conn.login(emailaddress.smtp_user, emailaddress.smtp_password)
logger.debug("SMTP - successful login to smtp server.")
except:
msg = f"SMTP - error login into smtp {emailaddress.smtp_user}."
logger.error(msg)
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg)
try:
conn.sendmail(emailaddress.email, email.receiver, msg.as_string())
logger.debug("SMTP - successfully send email.")
except socket.error as e:
msg = f"SMTP - error sending email: {str(e)}."
logger.error(msg)
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=msg)
finally:
conn.quit()

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,36 @@
import asyncio
from loguru import logger
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from .crud import get_email, get_emailaddress, set_email_paid
from .smtp import send_mail
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.get("tag") != "smtp":
return
email = await get_email(payment.checking_id)
if not email:
logger.error("SMTP: email can not by fetched")
return
emailaddress = await get_emailaddress(email.emailaddress_id)
if not emailaddress:
logger.error("SMTP: emailaddress can not by fetched")
return
await payment.set_pending(False)
await send_mail(emailaddress, email)
await set_email_paid(payment_hash=payment.payment_hash)

View File

@ -0,0 +1,23 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="About LNBits SMTP"
:content-inset-level="0.5"
>
<q-card>
<q-card-section>
<h5 class="text-subtitle1 q-my-none">
LNBits SMTP: Get paid sats to send emails
</h5>
<p>
Charge people for using sending an email via your smtp server<br />
<a
href="https://github.com/lnbits/lnbits/tree/main/lnbits/extensions/smtp"
>More details</a
>
<br />
<small>Created by, <a href="https://github.com/dni">dni</a></small>
</p>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@ -0,0 +1,185 @@
{% 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-card-section class="q-pa-none">
<h3 class="q-my-none">{{ email }}</h3>
<br />
<h5 class="q-my-none">{{ desc }}</h5>
<br />
<q-form @submit="Invoice()" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.receiver"
type="text"
label="Receiver"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.subject"
type="text"
label="Subject"
></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.message"
type="textarea"
label="Message "
></q-input>
<p>Total cost: {{ cost }} sats</p>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.receiver == '' || formDialog.data.subject == '' || formDialog.data.message == ''"
type="submit"
>Submit</q-btn
>
<q-btn @click="resetForm" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="receive.show" position="top" @hide="closeReceiveDialog">
<q-card
v-if="!receive.paymentReq"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="paymentReq"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn outline color="grey" @click="copyText(receive.paymentReq)"
>Copy invoice</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 %}
<script>
console.log('{{ cost }}')
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
paymentReq: null,
redirectUrl: null,
formDialog: {
show: false,
data: {
subject: '',
receiver: '',
message: ''
}
},
receive: {
show: false,
status: 'pending',
paymentReq: null
}
}
},
methods: {
resetForm: function (e) {
e.preventDefault()
this.formDialog.data.subject = ''
this.formDialog.data.receiver = ''
this.formDialog.data.message = ''
},
closeReceiveDialog: function () {
var checker = this.receive.paymentChecker
dismissMsg()
clearInterval(paymentChecker)
setTimeout(function () {}, 10000)
},
Invoice: function () {
var self = this
axios
.post('/smtp/api/v1/email/{{ emailaddress_id }}', {
emailaddress_id: '{{ emailaddress_id }}',
subject: self.formDialog.data.subject,
receiver: self.formDialog.data.receiver,
message: self.formDialog.data.message
})
.then(function (response) {
self.paymentReq = response.data.payment_request
self.paymentCheck = response.data.payment_hash
dismissMsg = self.$q.notify({
timeout: 0,
message: 'Waiting for payment...'
})
self.receive = {
show: true,
status: 'pending',
paymentReq: self.paymentReq
}
paymentChecker = setInterval(function () {
axios
.get('/smtp/api/v1/email/' + self.paymentCheck)
.then(function (res) {
console.log(res.data)
if (res.data.paid) {
clearInterval(paymentChecker)
self.receive = {
show: false,
status: 'complete',
paymentReq: null
}
dismissMsg()
console.log(self.formDialog)
self.formDialog.data.subject = ''
self.formDialog.data.receiver = ''
self.formDialog.data.message = ''
self.$q.notify({
type: 'positive',
message: 'Sent, thank you!',
icon: 'thumb_up'
})
console.log('END')
}
})
.catch(function (error) {
console.log(error)
LNbits.utils.notifyApiError(error)
})
}, 2000)
})
.catch(function (error) {
console.log(error)
LNbits.utils.notifyApiError(error)
})
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,528 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn
unelevated
color="primary"
@click="emailaddressDialog.show = true"
>New Emailaddress</q-btn
>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Emailaddresses</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportEmailaddressesCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="emailaddresses"
row-key="id"
:columns="emailaddressTable.columns"
:pagination.sync="emailaddressTable.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="link"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="props.row.displayUrl"
target="_blank"
></q-btn>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="updateEmailaddressDialog(props.row.id)"
icon="edit"
color="light-blue"
>
</q-btn>
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteEmailaddress(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Emails</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportEmailsCSV"
>Export to CSV</q-btn
>
</div>
</div>
<q-table
dense
flat
:data="emails"
row-key="id"
:columns="emailsTable.columns"
:pagination.sync="emailsTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<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 v-for="col in props.cols" :key="col.name" :props="props">
{{ col.value }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteEmail(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-4 col-lg-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} Sendmail extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "smtp/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="emailaddressDialog.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormData" class="q-gutter-md">
<q-select
filled
dense
emit-value
v-model="emailaddressDialog.data.wallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<q-input
filled
dense
emit-value
v-model.trim="emailaddressDialog.data.email"
type="text"
label="Emailaddress "
></q-input>
<q-input
filled
dense
emit-value
v-model.trim="emailaddressDialog.data.testemail"
type="text"
label="Emailaddress to test the server"
></q-input>
<q-input
filled
dense
v-model.trim="emailaddressDialog.data.smtp_server"
type="text"
label="SMTP Host"
>
</q-input>
<q-input
filled
dense
v-model.trim="emailaddressDialog.data.smtp_user"
type="text"
label="SMTP User"
>
</q-input>
<q-input
filled
dense
v-model.trim="emailaddressDialog.data.smtp_password"
type="password"
label="SMTP Password"
>
</q-input>
<q-input
filled
dense
v-model.trim="emailaddressDialog.data.smtp_port"
type="text"
label="SMTP Port"
>
</q-input>
<div id="lolcheck">
<q-checkbox
name="anonymize"
v-model="emailaddressDialog.data.anonymize"
label="ANONYMIZE, don't save mails, no addresses in tx"
/>
</div>
<q-input
filled
dense
v-model.trim="emailaddressDialog.data.description"
type="textarea"
label="Description "
>
</q-input>
<q-input
filled
dense
v-model.number="emailaddressDialog.data.cost"
type="number"
label="Amount per email in satoshis"
>
</q-input>
<div class="row q-mt-lg">
<q-btn
v-if="emailaddressDialog.data.id"
unelevated
color="primary"
type="submit"
>Update Form</q-btn
>
<q-btn
v-else
unelevated
color="primary"
:disable="enableButton()"
type="submit"
>Create Emailaddress</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 LNSendmail = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.displayUrl = ['/smtp/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
emailaddresses: [],
emails: [],
emailaddressTable: {
columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{
name: 'anonymize',
align: 'left',
label: 'Anonymize',
field: 'anonymize'
},
{
name: 'email',
align: 'left',
label: 'Emailaddress',
field: 'email'
},
{name: 'wallet', align: 'left', label: 'Wallet', field: 'wallet'},
{
name: 'description',
align: 'left',
label: 'Description',
field: 'description'
},
{
name: 'cost',
align: 'left',
label: 'Cost',
field: 'cost'
}
],
pagination: {
rowsPerPage: 10
}
},
emailsTable: {
columns: [
{
name: 'emailaddress',
align: 'left',
label: 'From',
field: 'emailaddress'
},
{
name: 'receiver',
align: 'left',
label: 'Receiver',
field: 'receiver'
},
{
name: 'subject',
align: 'left',
label: 'Subject',
field: 'subject'
},
{
name: 'message',
align: 'left',
label: 'Message',
field: 'message'
},
{
name: 'paid',
align: 'left',
label: 'Is paid',
field: 'paid'
}
],
pagination: {
rowsPerPage: 10
}
},
emailaddressDialog: {
show: false,
data: {}
}
}
},
methods: {
enableButton: function () {
return (
this.emailaddressDialog.data.cost == null ||
this.emailaddressDialog.data.cost < 0 ||
this.emailaddressDialog.data.testemail == null ||
this.emailaddressDialog.data.smtp_user == null ||
this.emailaddressDialog.data.smtp_password == null ||
this.emailaddressDialog.data.smtp_server == null ||
this.emailaddressDialog.data.smtp_port == null ||
this.emailaddressDialog.data.email == null ||
this.emailaddressDialog.data.description == null
)
},
getEmails: function () {
var self = this
LNbits.api
.request(
'GET',
'/smtp/api/v1/email?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.emails = response.data.map(function (obj) {
return LNSendmail(obj)
})
})
},
deleteEmail: function (emailId) {
var self = this
var email = _.findWhere(this.emails, {id: emailId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this email')
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/smtp/api/v1/email/' + emailId,
_.findWhere(self.g.user.wallets, {id: email.wallet}).inkey
)
.then(function (response) {
self.emails = _.reject(self.emails, function (obj) {
return obj.id == emailId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportEmailsCSV: function () {
LNbits.utils.exportCSV(this.emailsTable.columns, this.emails)
},
getEmailAddresses: function () {
var self = this
LNbits.api
.request(
'GET',
'/smtp/api/v1/emailaddress?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.emailaddresses = response.data.map(function (obj) {
return LNSendmail(obj)
})
})
},
sendFormData: function () {
var wallet = _.findWhere(this.g.user.wallets, {
id: this.emailaddressDialog.data.wallet
})
var data = this.emailaddressDialog.data
if (data.id) {
this.updateEmailaddress(wallet, data)
} else {
this.createEmailaddress(wallet, data)
}
},
createEmailaddress: function (wallet, data) {
var self = this
LNbits.api
.request('POST', '/smtp/api/v1/emailaddress', wallet.inkey, data)
.then(function (response) {
self.emailaddresses.push(LNSendmail(response.data))
self.emailaddressDialog.show = false
self.emailaddressDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
updateEmailaddressDialog: function (formId) {
var link = _.findWhere(this.emailaddresses, {id: formId})
this.emailaddressDialog.data = _.clone(link)
this.emailaddressDialog.show = true
},
updateEmailaddress: function (wallet, data) {
var self = this
LNbits.api
.request(
'PUT',
'/smtp/api/v1/emailaddress/' + data.id,
wallet.inkey,
data
)
.then(function (response) {
self.emailaddresses = _.reject(self.emailaddresses, function (obj) {
return obj.id == data.id
})
self.emailaddresses.push(LNSendmail(response.data))
self.emailaddressDialog.show = false
self.emailaddressDialog.data = {}
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteEmailaddress: function (emailaddressId) {
var self = this
var emailaddresses = _.findWhere(this.emailaddresses, {
id: emailaddressId
})
LNbits.utils
.confirmDialog(
'Are you sure you want to delete this emailaddress link?'
)
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/smtp/api/v1/emailaddress/' + emailaddressId,
_.findWhere(self.g.user.wallets, {id: emailaddresses.wallet})
.inkey
)
.then(function (response) {
self.emailaddresses = _.reject(self.emailaddresses, function (
obj
) {
return obj.id == emailaddressId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportEmailaddressesCSV: function () {
LNbits.utils.exportCSV(
this.emailaddressTable.columns,
this.emailaddresses
)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getEmailAddresses()
this.getEmails()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,40 @@
from http import HTTPStatus
from fastapi import Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import smtp_ext, smtp_renderer
from .crud import get_emailaddress
templates = Jinja2Templates(directory="templates")
@smtp_ext.get("/", response_class=HTMLResponse)
async def index(request: Request, user: User = Depends(check_user_exists)):
return smtp_renderer().TemplateResponse(
"smtp/index.html", {"request": request, "user": user.dict()}
)
@smtp_ext.get("/{emailaddress_id}")
async def display(request: Request, emailaddress_id):
emailaddress = await get_emailaddress(emailaddress_id)
if not emailaddress:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Emailaddress does not exist."
)
return smtp_renderer().TemplateResponse(
"smtp/display.html",
{
"request": request,
"emailaddress_id": emailaddress.id,
"email": emailaddress.email,
"desc": emailaddress.description,
"cost": emailaddress.cost,
},
)

View File

@ -0,0 +1,170 @@
from http import HTTPStatus
from fastapi import Depends, HTTPException, Query
from lnbits.core.crud import get_user
from lnbits.core.services import check_transaction_status, create_invoice
from lnbits.decorators import WalletTypeInfo, get_key_type
from lnbits.extensions.smtp.models import CreateEmail, CreateEmailaddress
from . import smtp_ext
from .crud import (
create_email,
create_emailaddress,
delete_email,
delete_emailaddress,
get_email,
get_emailaddress,
get_emailaddresses,
get_emails,
update_emailaddress,
)
from .smtp import valid_email
## EMAILS
@smtp_ext.get("/api/v1/email")
async def api_email(
g: WalletTypeInfo = Depends(get_key_type), all_wallets: bool = Query(False)
):
wallet_ids = [g.wallet.id]
if all_wallets:
user = await get_user(g.wallet.user)
if user:
wallet_ids = user.wallet_ids
return [email.dict() for email in await get_emails(wallet_ids)]
@smtp_ext.get("/api/v1/email/{payment_hash}")
async def api_smtp_send_email(payment_hash):
email = await get_email(payment_hash)
if not email:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST, detail="paymenthash is wrong"
)
emailaddress = await get_emailaddress(email.emailaddress_id)
try:
status = await check_transaction_status(email.wallet, payment_hash)
is_paid = not status.pending
except Exception:
return {"paid": False}
if is_paid:
if emailaddress.anonymize:
await delete_email(email.id)
return {"paid": True}
return {"paid": False}
@smtp_ext.post("/api/v1/email/{emailaddress_id}")
async def api_smtp_make_email(emailaddress_id, data: CreateEmail):
valid_email(data.receiver)
emailaddress = await get_emailaddress(emailaddress_id)
# If the request is coming for the non-existant emailaddress
if not emailaddress:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Emailaddress address does not exist.",
)
try:
memo = f"sent email from {emailaddress.email} to {data.receiver}"
if emailaddress.anonymize:
memo = "sent email"
payment_hash, payment_request = await create_invoice(
wallet_id=emailaddress.wallet,
amount=emailaddress.cost,
memo=memo,
extra={"tag": "smtp"},
)
except Exception as e:
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
email = await create_email(
payment_hash=payment_hash, wallet=emailaddress.wallet, data=data
)
if not email:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Email could not be fetched."
)
return {"payment_hash": payment_hash, "payment_request": payment_request}
@smtp_ext.delete("/api/v1/email/{email_id}")
async def api_email_delete(email_id, g: WalletTypeInfo = Depends(get_key_type)):
email = await get_email(email_id)
if not email:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="LNsubdomain does not exist."
)
if email.wallet != g.wallet.id:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your email.")
await delete_email(email_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
## EMAILADDRESSES
@smtp_ext.get("/api/v1/emailaddress")
async def api_emailaddresses(
g: WalletTypeInfo = Depends(get_key_type),
all_wallets: bool = Query(False),
):
wallet_ids = [g.wallet.id]
if all_wallets:
user = await get_user(g.wallet.user)
if user:
wallet_ids = user.wallet_ids
return [
emailaddress.dict() for emailaddress in await get_emailaddresses(wallet_ids)
]
@smtp_ext.post("/api/v1/emailaddress")
@smtp_ext.put("/api/v1/emailaddress/{emailaddress_id}")
async def api_emailaddress_create(
data: CreateEmailaddress,
emailaddress_id=None,
g: WalletTypeInfo = Depends(get_key_type),
):
if emailaddress_id:
emailaddress = await get_emailaddress(emailaddress_id)
if not emailaddress:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Emailadress does not exist."
)
if emailaddress.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not your emailaddress."
)
emailaddress = await update_emailaddress(emailaddress_id, **data.dict())
else:
emailaddress = await create_emailaddress(data=data)
return emailaddress.dict()
@smtp_ext.delete("/api/v1/emailaddress/{emailaddress_id}")
async def api_emailaddress_delete(
emailaddress_id, g: WalletTypeInfo = Depends(get_key_type)
):
emailaddress = await get_emailaddress(emailaddress_id)
if not emailaddress:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Emailaddress does not exist."
)
if emailaddress.wallet != g.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not your Emailaddress."
)
await delete_emailaddress(emailaddress_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)

View File

@ -33,7 +33,11 @@ async def create_tip(
async def create_tipjar(data: createTipJar) -> TipJar:
"""Create a new TipJar"""
await db.execute(
returning = "" if db.type == SQLITE else "RETURNING ID"
method = db.execute if db.type == SQLITE else db.fetchone
result = await (method)(
f"""
INSERT INTO tipjar.TipJars (
name,
@ -42,11 +46,16 @@ async def create_tipjar(data: createTipJar) -> TipJar:
onchain
)
VALUES (?, ?, ?, ?)
{returning}
""",
(data.name, data.wallet, data.webhook, data.onchain),
)
row = await db.fetchone("SELECT * FROM tipjar.TipJars LIMIT 1")
tipjar = TipJar(**row)
if db.type == SQLITE:
tipjar_id = result._result_proxy.lastrowid
else:
tipjar_id = result[0]
tipjar = await get_tipjar(tipjar_id)
assert tipjar
return tipjar

View File

@ -20,9 +20,6 @@ async def wait_for_paid_invoices():
async def on_invoice_paid(payment: Payment) -> None:
if not payment.extra:
return
if payment.extra.get("tag") != "tpos":
return

View File

@ -41,8 +41,9 @@ async def create_watch_wallet(user: str, w: WalletAccount) -> WalletAccount:
w.meta,
),
)
return await get_watch_wallet(wallet_id)
wallet = await get_watch_wallet(wallet_id)
assert wallet
return wallet
async def get_watch_wallet(wallet_id: str) -> Optional[WalletAccount]:
@ -121,11 +122,11 @@ async def create_fresh_addresses(
change_address=False,
) -> List[Address]:
if start_address_index > end_address_index:
return None
return []
wallet = await get_watch_wallet(wallet_id)
if not wallet:
return None
return []
branch_index = 1 if change_address else 0
@ -150,7 +151,7 @@ async def create_fresh_addresses(
# return fresh addresses
rows = await db.fetchall(
"""
SELECT * FROM watchonly.addresses
SELECT * FROM watchonly.addresses
WHERE wallet = ? AND branch_index = ? AND address_index >= ? AND address_index < ?
ORDER BY branch_index, address_index
""",
@ -172,7 +173,7 @@ async def get_address_at_index(
) -> Optional[Address]:
row = await db.fetchone(
"""
SELECT * FROM watchonly.addresses
SELECT * FROM watchonly.addresses
WHERE wallet = ? AND branch_index = ? AND address_index = ?
""",
(

View File

@ -1,6 +1,6 @@
from embit.descriptor import Descriptor, Key # type: ignore
from embit.descriptor.arguments import AllowedDerivation # type: ignore
from embit.networks import NETWORKS # type: ignore
from embit.descriptor import Descriptor, Key
from embit.descriptor.arguments import AllowedDerivation
from embit.networks import NETWORKS
def detect_network(k):

View File

@ -1,7 +1,7 @@
from sqlite3 import Row
from typing import List, Optional
from fastapi.param_functions import Query
from fastapi import Query
from pydantic import BaseModel
@ -35,7 +35,7 @@ class Address(BaseModel):
amount: int = 0
branch_index: int = 0
address_index: int
note: str = None
note: Optional[str] = None
has_activity: bool = False
@classmethod
@ -57,9 +57,9 @@ class TransactionInput(BaseModel):
class TransactionOutput(BaseModel):
amount: int
address: str
branch_index: int = None
address_index: int = None
wallet: str = None
branch_index: Optional[int] = None
address_index: Optional[int] = None
wallet: Optional[str] = None
class MasterPublicKey(BaseModel):

View File

@ -1,6 +1,5 @@
from fastapi.params import Depends
from fastapi import Depends, Request
from fastapi.templating import Jinja2Templates
from starlette.requests import Request
from starlette.responses import HTMLResponse
from lnbits.core.models import User

View File

@ -1,5 +1,6 @@
import json
from http import HTTPStatus
from typing import List
import httpx
from embit import finalizer, script
@ -7,9 +8,7 @@ from embit.ec import PublicKey
from embit.networks import NETWORKS
from embit.psbt import PSBT, DerivationPath
from embit.transaction import Transaction, TransactionInput, TransactionOutput
from fastapi import Query, Request
from fastapi.params import Depends
from starlette.exceptions import HTTPException
from fastapi import Depends, HTTPException, Query, Request
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.extensions.watchonly import watchonly_ext
@ -57,10 +56,8 @@ async def api_wallets_retrieve(
return []
@watchonly_ext.get("/api/v1/wallet/{wallet_id}")
async def api_wallet_retrieve(
wallet_id, wallet: WalletTypeInfo = Depends(get_key_type)
):
@watchonly_ext.get("/api/v1/wallet/{wallet_id}", dependencies=[Depends(get_key_type)])
async def api_wallet_retrieve(wallet_id: str):
w_wallet = await get_watch_wallet(wallet_id)
if not w_wallet:
@ -126,8 +123,10 @@ async def api_wallet_create_or_update(
return wallet.dict()
@watchonly_ext.delete("/api/v1/wallet/{wallet_id}")
async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin_key)):
@watchonly_ext.delete(
"/api/v1/wallet/{wallet_id}", dependencies=[Depends(require_admin_key)]
)
async def api_wallet_delete(wallet_id: str):
wallet = await get_watch_wallet(wallet_id)
if not wallet:
@ -144,16 +143,15 @@ async def api_wallet_delete(wallet_id, w: WalletTypeInfo = Depends(require_admin
#############################ADDRESSES##########################
@watchonly_ext.get("/api/v1/address/{wallet_id}")
async def api_fresh_address(wallet_id, w: WalletTypeInfo = Depends(get_key_type)):
@watchonly_ext.get("/api/v1/address/{wallet_id}", dependencies=[Depends(get_key_type)])
async def api_fresh_address(wallet_id: str):
address = await get_fresh_address(wallet_id)
assert address
return address.dict()
@watchonly_ext.put("/api/v1/address/{id}")
async def api_update_address(
id: str, req: Request, w: WalletTypeInfo = Depends(require_admin_key)
):
@watchonly_ext.put("/api/v1/address/{id}", dependencies=[Depends(require_admin_key)])
async def api_update_address(id: str, req: Request):
body = await req.json()
params = {}
# amout is only updated if the address has history
@ -162,9 +160,10 @@ async def api_update_address(
params["has_activity"] = True
if "note" in body:
params["note"] = str(body["note"])
params["note"] = body["note"]
address = await update_address(**params, id=id)
assert address
wallet = (
await get_watch_wallet(address.wallet)
@ -189,6 +188,7 @@ async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)
addresses = await get_addresses(wallet_id)
config = await get_config(w.wallet.user)
assert config
if not addresses:
await create_fresh_addresses(wallet_id, 0, config.receive_gap_limit)
@ -229,10 +229,8 @@ async def api_get_addresses(wallet_id, w: WalletTypeInfo = Depends(get_key_type)
#############################PSBT##########################
@watchonly_ext.post("/api/v1/psbt")
async def api_psbt_create(
data: CreatePsbt, w: WalletTypeInfo = Depends(require_admin_key)
):
@watchonly_ext.post("/api/v1/psbt", dependencies=[Depends(require_admin_key)])
async def api_psbt_create(data: CreatePsbt):
try:
vin = [
TransactionInput(bytes.fromhex(inp.tx_id), inp.vout) for inp in data.inputs
@ -246,7 +244,7 @@ async def api_psbt_create(
for _, masterpub in enumerate(data.masterpubs):
descriptors[masterpub.id] = parse_key(masterpub.public_key)
inputs_extra = []
inputs_extra: List[dict] = []
for i, inp in enumerate(data.inputs):
bip32_derivations = {}
@ -266,14 +264,15 @@ async def api_psbt_create(
tx = Transaction(vin=vin, vout=vout)
psbt = PSBT(tx)
for i, inp in enumerate(inputs_extra):
psbt.inputs[i].bip32_derivations = inp["bip32_derivations"]
psbt.inputs[i].non_witness_utxo = inp.get("non_witness_utxo", None)
for i, inp_extra in enumerate(inputs_extra):
psbt.inputs[i].bip32_derivations = inp_extra["bip32_derivations"]
psbt.inputs[i].non_witness_utxo = inp_extra.get("non_witness_utxo", None)
outputs_extra = []
bip32_derivations = {}
for i, out in enumerate(data.outputs):
if out.branch_index == 1:
assert out.wallet
descriptor = descriptors[out.wallet][0]
d = descriptor.derive(out.address_index, out.branch_index)
for k in d.keys:
@ -282,8 +281,8 @@ async def api_psbt_create(
)
outputs_extra.append({"bip32_derivations": bip32_derivations})
for i, out in enumerate(outputs_extra):
psbt.outputs[i].bip32_derivations = out["bip32_derivations"]
for i, out_extra in enumerate(outputs_extra):
psbt.outputs[i].bip32_derivations = out_extra["bip32_derivations"]
return psbt.to_string()
@ -360,7 +359,8 @@ async def api_tx_broadcast(
else config.mempool_endpoint + "/testnet"
)
async with httpx.AsyncClient() as client:
r = await client.post(endpoint + "/api/tx", data=data.tx_hex)
r = await client.post(endpoint + "/api/tx", content=data.tx_hex)
r.raise_for_status()
tx_id = r.text
return tx_id
except Exception as e:
@ -375,6 +375,7 @@ async def api_update_config(
data: Config, w: WalletTypeInfo = Depends(require_admin_key)
):
config = await update_config(data, user=w.wallet.user)
assert config
return config.dict()

View File

@ -92,8 +92,6 @@ exclude = """(?x)(
^lnbits/extensions/bleskomat.
| ^lnbits/extensions/boltz.
| ^lnbits/extensions/livestream.
| ^lnbits/extensions/lnurldevice.
| ^lnbits/extensions/watchonly.
| ^lnbits/wallets/lnd_grpc_files.
)"""

Binary file not shown.