Merge branch 'lnbits:main' into main

This commit is contained in:
Uthpala 2022-12-04 23:31:17 +01:00 committed by GitHub
commit f52e8ebfa1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 5856 additions and 559 deletions

View File

@ -103,3 +103,8 @@ ECLAIR_PASS=eclairpw
# Enter /api in LightningTipBot to get your key
LNTIPS_API_KEY=LNTIPS_ADMIN_KEY
LNTIPS_API_ENDPOINT=https://ln.tips
# Cashu Mint
# Use a long-enough random (!) private key.
# Once set, you cannot change this key as for now.
CASHU_PRIVATE_KEY="SuperSecretPrivateKey"

View File

@ -9,53 +9,10 @@ nav_order: 2
Websockets
=================
`websockets` are a great way to add a two way instant data channel between server and client. This example was taken from the `copilot` extension, we create a websocket endpoint which can be restricted by `id`, then can feed it data to broadcast to any client on the socket using the `updater(extension_id, data)` function (`extension` has been used in place of an extension name, wreplace to your own extension):
`websockets` are a great way to add a two way instant data channel between server and client.
LNbits has a useful in built websocket tool. With a websocket client connect to (obv change `somespecificid`) `wss://legend.lnbits.com/api/v1/ws/somespecificid` (you can use an online websocket tester). Now make a get to `https://legend.lnbits.com/api/v1/ws/somespecificid/somedata`. You can send data to that websocket by using `from lnbits.core.services import websocketUpdater` and the function `websocketUpdater("somespecificid", "somdata")`.
```sh
from fastapi import Request, WebSocket, WebSocketDisconnect
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, extension_id: str):
await websocket.accept()
websocket.id = extension_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, extension_id: str):
for connection in self.active_connections:
if connection.id == extension_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@extension_ext.websocket("/ws/{extension_id}", name="extension.websocket_by_id")
async def websocket_endpoint(websocket: WebSocket, extension_id: str):
await manager.connect(websocket, extension_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(extension_id, data):
extension = await get_extension(extension_id)
if not extension:
return
await manager.send_personal_message(f"{data}", extension_id)
```
Example vue-js function for listening to the websocket:
@ -67,16 +24,16 @@ initWs: async function () {
document.domain +
':' +
location.port +
'/extension/ws/' +
self.extension.id
'/api/v1/ws/' +
self.item.id
} else {
localUrl =
'ws://' +
document.domain +
':' +
location.port +
'/extension/ws/' +
self.extension.id
'/api/v1/ws/' +
self.item.id
}
this.ws = new WebSocket(localUrl)
this.ws.addEventListener('message', async ({data}) => {

View File

@ -75,8 +75,8 @@ LNBITS_DATA_FOLDER=data LNBITS_BACKEND_WALLET_CLASS=LNbitsWallet LNBITS_ENDPOINT
```sh
git clone https://github.com/lnbits/lnbits-legend.git
cd lnbits-legend/
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3-venv'
python3 -m venv venv
# ensure you have virtualenv installed, on debian/ubuntu 'apt install python3.9-venv'
python3.9 -m venv venv
# If you have problems here, try `sudo apt install -y pkg-config libpq-dev`
./venv/bin/pip install -r requirements.txt
# create the data folder and the .env file
@ -106,7 +106,7 @@ docker run --detach --publish 5000:5000 --name lnbits-legend --volume ${PWD}/.en
## Option 5: Fly.io
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNBits for free on Fly.io for personal use.
Fly.io is a docker container hosting platform that has a generous free tier. You can host LNbits for free on Fly.io for personal use.
First, sign up for an account at [Fly.io](https://fly.io) (no credit card required).
@ -169,7 +169,7 @@ kill_timeout = 30
...
```
Next, create a volume to store the sqlite database for LNBits. Be sure to choose the same region for the volume that you chose earlier.
Next, create a volume to store the sqlite database for LNbits. Be sure to choose the same region for the volume that you chose earlier.
```
fly volumes create lnbits_data --size 1

View File

@ -65,8 +65,7 @@ async def migrate_databases():
(db_name, version, version),
)
async def run_migration(db, migrations_module):
db_name = migrations_module.__name__.split(".")[-2]
async def run_migration(db, migrations_module, db_name):
for key, migrate in migrations_module.__dict__.items():
match = match = matcher.match(key)
if match:
@ -97,20 +96,24 @@ async def migrate_databases():
rows = await (await conn.execute("SELECT * FROM dbversions")).fetchall()
current_versions = {row["db"]: row["version"] for row in rows}
matcher = re.compile(r"^m(\d\d\d)_")
await run_migration(conn, core_migrations)
db_name = core_migrations.__name__.split(".")[-2]
await run_migration(conn, core_migrations, db_name)
for ext in get_valid_extensions():
try:
ext_migrations = importlib.import_module(
f"lnbits.extensions.{ext.code}.migrations"
module_str = (
ext.migration_module or f"lnbits.extensions.{ext.code}.migrations"
)
ext_migrations = importlib.import_module(module_str)
ext_db = importlib.import_module(f"lnbits.extensions.{ext.code}").db
db_name = ext.db_name or module_str.split(".")[-2]
except ImportError:
raise ImportError(
f"Please make sure that the extension `{ext.code}` has a migrations file."
)
async with ext_db.connect() as ext_conn:
await run_migration(ext_conn, ext_migrations)
await run_migration(ext_conn, ext_migrations, db_name)
logger.info("✔️ All migrations done.")

View File

@ -2,11 +2,11 @@ import asyncio
import json
from binascii import unhexlify
from io import BytesIO
from typing import Dict, Optional, Tuple
from typing import Dict, List, Optional, Tuple
from urllib.parse import parse_qs, urlparse
import httpx
from fastapi import Depends
from fastapi import Depends, WebSocket, WebSocketDisconnect
from lnurl import LnurlErrorResponse
from lnurl import decode as decode_lnurl # type: ignore
from loguru import logger
@ -382,3 +382,28 @@ async def check_transaction_status(
# WARN: this same value must be used for balance check and passed to WALLET.pay_invoice(), it may cause a vulnerability if the values differ
def fee_reserve(amount_msat: int) -> int:
return max(int(RESERVE_FEE_MIN), int(amount_msat * RESERVE_FEE_PERCENT / 100.0))
class WebsocketConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
logger.debug(websocket)
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_data(self, message: str, item_id: str):
for connection in self.active_connections:
if connection.path_params["item_id"] == item_id:
await connection.send_text(message)
websocketManager = WebsocketConnectionManager()
async def websocketUpdater(item_id, data):
return await websocketManager.send_data(f"{data}", item_id)

View File

@ -12,7 +12,15 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse
import async_timeout
import httpx
import pyqrcode
from fastapi import Depends, Header, Query, Request, Response
from fastapi import (
Depends,
Header,
Query,
Request,
Response,
WebSocket,
WebSocketDisconnect,
)
from fastapi.exceptions import HTTPException
from fastapi.params import Body
from loguru import logger
@ -56,6 +64,8 @@ from ..services import (
create_invoice,
pay_invoice,
perform_lnurlauth,
websocketManager,
websocketUpdater,
)
from ..tasks import api_invoice_listeners
@ -697,3 +707,34 @@ async def api_auditor(wallet: WalletTypeInfo = Depends(get_key_type)):
"delta_msats": delta,
"timestamp": int(time.time()),
}
##################UNIVERSAL WEBSOCKET MANAGER########################
@core_app.websocket("/api/v1/ws/{item_id}")
async def websocket_connect(websocket: WebSocket, item_id: str):
await websocketManager.connect(websocket)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
websocketManager.disconnect(websocket)
@core_app.post("/api/v1/ws/{item_id}")
async def websocket_update_post(item_id: str, data: str):
try:
await websocketUpdater(item_id, data)
return {"sent": True, "data": data}
except:
return {"sent": False, "data": data}
@core_app.get("/api/v1/ws/{item_id}/{data}")
async def websocket_update_get(item_id: str, data: str):
try:
await websocketUpdater(item_id, data)
return {"sent": True, "data": data}
except:
return {"sent": False, "data": data}

View File

@ -86,7 +86,8 @@ class Connection(Compat):
return raw_html
# tuple to list and back to tuple
values = tuple([cleanhtml(l) for l in list(values)])
value_list = [values] if isinstance(values, str) else list(values)
values = tuple([cleanhtml(l) for l in value_list])
return values
async def fetchall(self, query: str, values: tuple = ()) -> list:

View File

@ -6,7 +6,7 @@ This extension allows you to link your Bolt Card (or other compatible NXP NTAG d
**Disclaimer:** ***Use this only if you either know what you are doing or are a reckless lightning pioneer. Only you are responsible for all your sats, cards and other devices. Always backup all your card keys!***
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNBits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
***In order to use this extension you need to be able to setup your own card.*** That means writing a URL template pointing to your LNbits instance, configuring some SUN (SDM) settings and optionally changing the card's keys. There's a [guide](https://www.whitewolftech.com/articles/payment-card/) to set it up with a card reader connected to your computer. It can be done (without setting the keys) with [TagWriter app by NXP](https://play.google.com/store/apps/details?id=com.nxp.nfc.tagwriter) Android app. Last but not least, an OSS android app by name [bolt-nfc-android-app](https://github.com/boltcard/bolt-nfc-android-app) is being developed for these purposes. It's available from Google Play [here](https://play.google.com/store/apps/details?id=com.lightningnfcapp).
## About the keys
@ -25,12 +25,12 @@ So far, regarding the keys, the app can only write a new key set on an empty car
- Read the card with the app. Note UID so you can fill it in the extension later.
- Write the link on the card. It shoud be like `YOUR_LNBITS_DOMAIN/boltcards/api/v1/scan/{external_id}`
- `{external_id}` should be replaced with the External ID found in the LNBits dialog.
- `{external_id}` should be replaced with the External ID found in the LNbits dialog.
- Add new card in the extension.
- Set a max sats per transaction. Any transaction greater than this amount will be rejected.
- Set a max sats per day. After the card spends this amount of sats in a day, additional transactions will be rejected.
- Set a card name. This is just for your reference inside LNBits.
- Set a card name. This is just for your reference inside LNbits.
- Set the card UID. This is the unique identifier on your NFC card and is 7 bytes.
- If on an Android device with a newish version of Chrome, you can click the icon next to the input and tap your card to autofill this field.
- Advanced Options

View File

@ -0,0 +1,11 @@
# Cashu
## Create ecash mint for pegging in/out of ecash
### Usage
1. Enable extension
2. Create a Mint
3. Share wallet

View File

@ -0,0 +1,48 @@
import asyncio
from environs import Env # type: ignore
from fastapi import APIRouter
from fastapi.staticfiles import StaticFiles
from lnbits.db import Database
from lnbits.helpers import template_renderer
from lnbits.tasks import catch_everything_and_restart
db = Database("ext_cashu")
import sys
cashu_static_files = [
{
"path": "/cashu/static",
"app": StaticFiles(directory="lnbits/extensions/cashu/static"),
"name": "cashu_static",
}
]
from cashu.mint.ledger import Ledger
env = Env()
env.read_env()
ledger = Ledger(
db=db,
seed=env.str("CASHU_PRIVATE_KEY", default="SuperSecretPrivateKey"),
derivation_path="0/0/0/1",
)
cashu_ext: APIRouter = APIRouter(prefix="/cashu", tags=["cashu"])
def cashu_renderer():
return template_renderer(["lnbits/extensions/cashu/templates"])
from .tasks import startup_cashu_mint, wait_for_paid_invoices
from .views import * # noqa
from .views_api import * # noqa
def cashu_start():
loop = asyncio.get_event_loop()
loop.create_task(catch_everything_and_restart(startup_cashu_mint))
loop.create_task(catch_everything_and_restart(wait_for_paid_invoices))

View File

@ -0,0 +1,7 @@
{
"name": "Cashu",
"short_description": "Ecash mint and wallet",
"icon": "account_balance",
"contributors": ["calle", "vlad", "arcbtc"],
"hidden": false
}

View File

@ -0,0 +1,63 @@
import os
import random
import time
from binascii import hexlify, unhexlify
from typing import Any, List, Optional, Union
from cashu.core.base import MintKeyset
from embit import bip32, bip39, ec, script
from embit.networks import NETWORKS
from loguru import logger
from lnbits.db import Connection, Database
from lnbits.helpers import urlsafe_short_hash
from . import db
from .models import Cashu, Pegs, Promises, Proof
async def create_cashu(
cashu_id: str, keyset_id: str, wallet_id: str, data: Cashu
) -> Cashu:
await db.execute(
"""
INSERT INTO cashu.cashu (id, wallet, name, tickershort, fraction, maxsats, coins, keyset_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
cashu_id,
wallet_id,
data.name,
data.tickershort,
data.fraction,
data.maxsats,
data.coins,
keyset_id,
),
)
cashu = await get_cashu(cashu_id)
assert cashu, "Newly created cashu couldn't be retrieved"
return cashu
async def get_cashu(cashu_id) -> Optional[Cashu]:
row = await db.fetchone("SELECT * FROM cashu.cashu WHERE id = ?", (cashu_id,))
return Cashu(**row) if row else None
async def get_cashus(wallet_ids: Union[str, List[str]]) -> List[Cashu]:
if isinstance(wallet_ids, str):
wallet_ids = [wallet_ids]
q = ",".join(["?"] * len(wallet_ids))
rows = await db.fetchall(
f"SELECT * FROM cashu.cashu WHERE wallet IN ({q})", (*wallet_ids,)
)
return [Cashu(**row) for row in rows]
async def delete_cashu(cashu_id) -> None:
await db.execute("DELETE FROM cashu.cashu WHERE id = ?", (cashu_id,))

View File

@ -0,0 +1,33 @@
async def m001_initial(db):
"""
Initial cashu table.
"""
await db.execute(
"""
CREATE TABLE cashu.cashu (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
name TEXT NOT NULL,
tickershort TEXT DEFAULT 'sats',
fraction BOOL,
maxsats INT,
coins INT,
keyset_id TEXT NOT NULL,
issued_sat INT
);
"""
)
"""
Initial cashus table.
"""
await db.execute(
"""
CREATE TABLE cashu.pegs (
id TEXT PRIMARY KEY,
wallet TEXT NOT NULL,
inout BOOL NOT NULL,
amount INT
);
"""
)

View File

@ -0,0 +1,147 @@
from sqlite3 import Row
from typing import List, Union
from fastapi import Query
from pydantic import BaseModel
class Cashu(BaseModel):
id: str = Query(None)
name: str = Query(None)
wallet: str = Query(None)
tickershort: str = Query(None)
fraction: bool = Query(None)
maxsats: int = Query(0)
coins: int = Query(0)
keyset_id: str = Query(None)
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class Pegs(BaseModel):
id: str
wallet: str
inout: str
amount: str
@classmethod
def from_row(cls, row: Row):
return cls(**dict(row))
class PayLnurlWData(BaseModel):
lnurl: str
class Promises(BaseModel):
id: str
amount: int
B_b: str
C_b: str
cashu_id: str
class Proof(BaseModel):
amount: int
secret: str
C: str
reserved: bool = False # whether this proof is reserved for sending
send_id: str = "" # unique ID of send attempt
time_created: str = ""
time_reserved: str = ""
@classmethod
def from_row(cls, row: Row):
return cls(
amount=row[0],
C=row[1],
secret=row[2],
reserved=row[3] or False,
send_id=row[4] or "",
time_created=row[5] or "",
time_reserved=row[6] or "",
)
@classmethod
def from_dict(cls, d: dict):
assert "secret" in d, "no secret in proof"
assert "amount" in d, "no amount in proof"
return cls(
amount=d.get("amount"),
C=d.get("C"),
secret=d.get("secret"),
reserved=d.get("reserved") or False,
send_id=d.get("send_id") or "",
time_created=d.get("time_created") or "",
time_reserved=d.get("time_reserved") or "",
)
def to_dict(self):
return dict(amount=self.amount, secret=self.secret, C=self.C)
def __getitem__(self, key):
return self.__getattribute__(key)
def __setitem__(self, key, val):
self.__setattr__(key, val)
class Proofs(BaseModel):
"""TODO: Use this model"""
proofs: List[Proof]
class Invoice(BaseModel):
amount: int
pr: str
hash: str
issued: bool = False
@classmethod
def from_row(cls, row: Row):
return cls(
amount=int(row[0]),
pr=str(row[1]),
hash=str(row[2]),
issued=bool(row[3]),
)
class BlindedMessage(BaseModel):
amount: int
B_: str
class BlindedSignature(BaseModel):
amount: int
C_: str
@classmethod
def from_dict(cls, d: dict):
return cls(
amount=d["amount"],
C_=d["C_"],
)
class MintPayloads(BaseModel):
blinded_messages: List[BlindedMessage] = []
class SplitPayload(BaseModel):
proofs: List[Proof]
amount: int
output_data: MintPayloads
class CheckPayload(BaseModel):
proofs: List[Proof]
class MeltPayload(BaseModel):
proofs: List[Proof]
amount: int
invoice: str

View File

@ -0,0 +1,37 @@
function unescapeBase64Url(str) {
return (str + '==='.slice((str.length + 3) % 4))
.replace(/-/g, '+')
.replace(/_/g, '/')
}
function escapeBase64Url(str) {
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
}
const uint8ToBase64 = (function (exports) {
'use strict'
var fromCharCode = String.fromCharCode
var encode = function encode(uint8array) {
var output = []
for (var i = 0, length = uint8array.length; i < length; i++) {
output.push(fromCharCode(uint8array[i]))
}
return btoa(output.join(''))
}
var asCharCode = function asCharCode(c) {
return c.charCodeAt(0)
}
var decode = function decode(chars) {
return Uint8Array.from(atob(chars), asCharCode)
}
exports.decode = decode
exports.encode = encode
return exports
})({})

View File

@ -0,0 +1,39 @@
async function hashToCurve(secretMessage) {
console.log(
'### secretMessage',
nobleSecp256k1.utils.bytesToHex(secretMessage)
)
let point
while (!point) {
const hash = await nobleSecp256k1.utils.sha256(secretMessage)
const hashHex = nobleSecp256k1.utils.bytesToHex(hash)
const pointX = '02' + hashHex
console.log('### pointX', pointX)
try {
point = nobleSecp256k1.Point.fromHex(pointX)
console.log('### point', point.toHex())
} catch (error) {
secretMessage = await nobleSecp256k1.utils.sha256(secretMessage)
}
}
return point
}
async function step1Alice(secretMessage) {
// todo: document & validate `secretMessage` format
secretMessage = uint8ToBase64.encode(secretMessage)
secretMessage = new TextEncoder().encode(secretMessage)
const Y = await hashToCurve(secretMessage)
const rpk = nobleSecp256k1.utils.randomPrivateKey()
const r = bytesToNumber(rpk)
const P = nobleSecp256k1.Point.fromPrivateKey(r)
const B_ = Y.add(P)
return {B_: B_.toHex(true), r: nobleSecp256k1.utils.bytesToHex(rpk)}
}
function step3Alice(C_, r, A) {
// const rInt = BigInt(r)
const rInt = bytesToNumber(r)
const C = C_.subtract(A.multiply(rInt))
return C
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
function splitAmount(value) {
const chunks = []
for (let i = 0; i < 32; i++) {
const mask = 1 << i
if ((value & mask) !== 0) chunks.push(Math.pow(2, i))
}
return chunks
}
function bytesToNumber(bytes) {
return hexToNumber(nobleSecp256k1.utils.bytesToHex(bytes))
}
function bigIntStringify(key, value) {
return typeof value === 'bigint' ? value.toString() : value
}
function hexToNumber(hex) {
if (typeof hex !== 'string') {
throw new TypeError('hexToNumber: expected string, got ' + typeof hex)
}
return BigInt(`0x${hex}`)
}

View File

@ -0,0 +1,33 @@
import asyncio
import json
from cashu.core.migrations import migrate_databases
from cashu.mint import migrations
from lnbits.core.models import Payment
from lnbits.tasks import register_invoice_listener
from . import db, ledger
from .crud import get_cashu
async def startup_cashu_mint():
await migrate_databases(db, migrations)
await ledger.load_used_proofs()
await ledger.init_keysets(autosave=False)
pass
async def wait_for_paid_invoices():
invoice_queue = asyncio.Queue()
register_invoice_listener(invoice_queue)
while True:
payment = await invoice_queue.get()
await on_invoice_paid(payment)
async def on_invoice_paid(payment: Payment) -> None:
if payment.extra and not payment.extra.get("tag") == "cashu":
return
return

View File

@ -0,0 +1,80 @@
<q-expansion-item
group="extras"
icon="swap_vertical_circle"
label="API info"
:content-inset-level="0.5"
>
<q-btn flat label="Swagger API" type="a" href="../docs#/cashu"></q-btn>
<!-- <q-expansion-item group="api" dense expand-separator label="List TPoS">
<q-card>
<q-card-section>
<code><span class="text-blue">GET</span> /cashu/api/v1/mints</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 200 OK (application/json)
</h5>
<code>[&lt;cashu_object&gt;, ...]</code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X GET {{ request.base_url }}cashu/api/v1/mints -H "X-Api-Key:
&lt;invoice_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item group="api" dense expand-separator label="Create a TPoS">
<q-card>
<q-card-section>
<code><span class="text-green">POST</span> /cashu/api/v1/mints</code>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;invoice_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
<code
>{"name": &lt;string&gt;, "currency": &lt;string*ie USD*&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">
Returns 201 CREATED (application/json)
</h5>
<code
>{"currency": &lt;string&gt;, "id": &lt;string&gt;, "name":
&lt;string&gt;, "wallet": &lt;string&gt;}</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X POST {{ request.base_url }}cashu/api/v1/mints -d '{"name":
&lt;string&gt;, "currency": &lt;string&gt;}' -H "Content-type:
application/json" -H "X-Api-Key: &lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item>
<q-expansion-item
group="api"
dense
expand-separator
label="Delete a TPoS"
class="q-pb-md"
>
<q-card>
<q-card-section>
<code
><span class="text-pink">DELETE</span>
/cashu/api/v1/mints/&lt;cashu_id&gt;</code
>
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
<code>{"X-Api-Key": &lt;admin_key&gt;}</code><br />
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
<code></code>
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
<code
>curl -X DELETE {{ request.base_url
}}cashu/api/v1/mints/&lt;cashu_id&gt; -H "X-Api-Key:
&lt;admin_key&gt;"
</code>
</q-card-section>
</q-card>
</q-expansion-item> -->
</q-expansion-item>

View File

@ -0,0 +1,13 @@
<q-expansion-item group="extras" icon="info" label="About">
<q-card>
<q-card-section>
<p>Create Cashu ecash mints and wallets.</p>
<small
>Created by
<a href="https://github.com/arcbtc" target="_blank">arcbtc</a>,
<a href="https://github.com/motorina0" target="_blank">vlad</a>,
<a href="https://github.com/calle" target="_blank">calle</a>.</small
>
</q-card-section>
</q-card>
</q-expansion-item>

View File

@ -0,0 +1,367 @@
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<b>Cashu mint and wallet</b>
<p></p>
<p>
Here you can create multiple cashu mints that you can share. Each mint
can service many users but all ecash tokens of a mint are only valid
inside that mint and not across different mints. To exchange funds
between mints, use Lightning payments.
</p>
<b>Important</b>
<p></p>
<p>
If you are the operator of this LNbits instance, make sure to set
CASHU_PRIVATE_KEY="randomkey" in your configuration file. Do not
create mints before setting the key and do not change the key once
set.
</p>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Mints</h5>
</div>
<div class="col-auto">
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
</div>
</div>
<q-table
dense
flat
:data="cashus"
row-key="id"
:columns="cashusTable.columns"
:pagination.sync="cashusTable.pagination"
>
{% raw %}
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label }}
</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
unelevated
dense
size="xs"
icon="account_balance_wallet"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'wallet/?' + 'mint_id=' + props.row.id"
target="_blank"
><q-tooltip>Shareable wallet</q-tooltip></q-btn
>
<q-btn
unelevated
dense
size="xs"
icon="account_balance"
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
type="a"
:href="'mint/' + props.row.id"
target="_blank"
><q-tooltip>Shareable mint page</q-tooltip></q-btn
>
</q-td>
<q-td v-for="col in props.cols" :key="col.name" :props="props">
{{ (col.name == 'tip_options' && col.value ?
JSON.parse(col.value).join(", ") : col.value) }}
</q-td>
<q-td auto-width>
<q-btn
flat
dense
size="xs"
@click="deleteMint(props.row.id)"
icon="cancel"
color="pink"
></q-btn>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
<q-btn
class="q-pt-l"
unelevated
color="primary"
@click="formDialog.show = true"
>New Mint</q-btn
>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Cashu extension</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list>
{% include "cashu/_api_docs.html" %}
<q-separator></q-separator>
{% include "cashu/_cashu.html" %}
</q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
<q-form @submit="createMint" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialog.data.name"
label="Mint Name"
placeholder="Cashu Mint"
></q-input>
<q-select
filled
dense
emit-value
v-model="formDialog.data.wallet"
:options="g.user.walletOptions"
label="Cashu wallet *"
></q-select>
<!-- <q-toggle
v-model="toggleAdvanced"
label="Show advanced options"
></q-toggle>
<div v-show="toggleAdvanced">
<div class="row">
<div class="col-5">
<q-checkbox
v-model="formDialog.data.fraction"
color="primary"
label="sats/coins?"
>
<q-tooltip
>Use with hedging extension to create a stablecoin!</q-tooltip
>
</q-checkbox>
</div>
<div class="col-7">
<q-input
v-if="!formDialog.data.fraction"
filled
dense
type="number"
v-model.trim="formDialog.data.cost"
label="Sat coin cost (optional)"
value="1"
type="number"
></q-input>
<q-input
v-if="!formDialog.data.fraction"
filled
dense
v-model.trim="formDialog.data.tickershort"
label="Ticker shorthand"
placeholder="sats"
#
></q-input>
</div>
</div>
<q-input
class="q-mt-md"
filled
dense
type="number"
v-model.trim="formDialog.data.maxsats"
label="Maximum mint liquidity (optional)"
placeholder="∞"
></q-input>
<q-input
class="q-mt-md"
filled
dense
type="number"
v-model.trim="formDialog.data.coins"
label="Coins that 'exist' in mint (optional)"
placeholder="∞"
></q-input>
</div> -->
<div class="row q-mt-md">
<q-btn
unelevated
color="primary"
:disable="formDialog.data.wallet == null || formDialog.data.name == null"
type="submit"
>Create Mint
</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<script>
var mapMint = function (obj) {
obj.date = Quasar.utils.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
obj.cashu = ['/cashu/', obj.id].join('')
return obj
}
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
cashus: [],
hostname: location.protocol + '//' + location.host + '/cashu/mint/',
toggleAdvanced: false,
cashusTable: {
columns: [
{name: 'id', align: 'left', label: 'Mint ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'},
// {
// name: 'tickershort',
// align: 'left',
// label: 'Ticker',
// field: 'tickershort'
// },
{
name: 'wallet',
align: 'left',
label: 'Mint wallet',
field: 'wallet'
}
// {
// name: 'fraction',
// align: 'left',
// label: 'Using fraction',
// field: 'fraction'
// },
// {
// name: 'maxsats',
// align: 'left',
// label: 'Max Sats',
// field: 'maxsats'
// },
// {
// name: 'coins',
// align: 'left',
// label: 'No. of coins',
// field: 'coins'
// }
],
pagination: {
rowsPerPage: 10
}
},
formDialog: {
show: false,
data: {fraction: false}
}
}
},
methods: {
closeFormDialog: function () {
this.formDialog.data = {}
},
getMints: function () {
var self = this
LNbits.api
.request(
'GET',
'/cashu/api/v1/mints?all_wallets=true',
this.g.user.wallets[0].inkey
)
.then(function (response) {
self.cashus = response.data.map(function (obj) {
return mapMint(obj)
})
})
},
createMint: function () {
if (this.formDialog.data.maxliquid == null) {
this.formDialog.data.maxliquid = 0
}
var data = {
name: this.formDialog.data.name,
tickershort: this.formDialog.data.tickershort,
maxliquid: this.formDialog.data.maxliquid
}
var self = this
LNbits.api
.request(
'POST',
'/cashu/api/v1/mints',
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
.inkey,
data
)
.then(function (response) {
self.cashus.push(mapMint(response.data))
self.formDialog.show = false
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
},
deleteMint: function (cashuId) {
var self = this
var cashu = _.findWhere(this.cashus, {id: cashuId})
console.log(cashu)
LNbits.utils
.confirmDialog(
"Are you sure you want to delete this Mint? This mint's users will not be able to redeem their tokens!"
)
.onOk(function () {
LNbits.api
.request(
'DELETE',
'/cashu/api/v1/mints/' + cashuId,
_.findWhere(self.g.user.wallets, {id: cashu.wallet}).adminkey
)
.then(function (response) {
self.cashus = _.reject(self.cashus, function (obj) {
return obj.id == cashuId
})
})
.catch(function (error) {
LNbits.utils.notifyApiError(error)
})
})
},
exportCSV: function () {
LNbits.utils.exportCSV(this.cashusTable.columns, this.cashus)
}
},
created: function () {
if (this.g.user.wallets.length) {
this.getMints()
}
}
})
</script>
{% endblock %}

View File

@ -0,0 +1,76 @@
{% extends "public.html" %} {% block page %}
<div class="row q-col-gutter-md justify-center">
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
<q-card class="q-pa-lg q-mb-xl">
<q-card-section class="q-pa-none">
<center>
<q-icon
name="account_balance"
class="text-grey"
style="font-size: 10rem"
></q-icon>
<h4 class="q-mt-none q-mb-md">{{ mint_name }}</h4>
<a
class="q-my-xl text-white"
style="font-size: 1.5rem"
href="../wallet?mint_id={{ mint_id }}"
>Open wallet</a
>
</center>
</q-card-section>
</q-card>
<q-card class="q-pa-lg q-mb-xl">
<q-card-section class="q-pa-none">
<h5 class="q-my-md">Read the following carefully!</h5>
<p>
This is a
<a href="https://cashu.space/" style="color: white" target="”_blank”"
>Cashu</a
>
mint. Cashu is an ecash system for Bitcoin.
</p>
<p>
<strong>Open this page in your native browser</strong><br />
Before you continue to the wallet, make sure to open this page in your
device's native browser application (Safari for iOS, Chrome for
Android). Do not use Cashu in an embedded browser that opens when you
click a link in a messenger.
</p>
<p>
<strong>Add wallet to home screen</strong><br />
You can add Cashu to your home screen as a progressive web app (PWA).
After opening the wallet in your browser (click the link above), on
Android (Chrome), click the menu at the upper right. On iOS (Safari),
click the share button. Now press the Add to Home screen button.
</p>
<p>
<strong>Backup your wallet</strong><br />
Ecash is a bearer asset. That means losing access to your wallet will
make you lose your funds. The wallet stores ecash tokens on your
device's database. If you lose the link or delete your your data
without backing up, you will lose your tokens. Press the Backup button
in the wallet to download a copy of your tokens.
</p>
<p>
<strong>This service is in BETA</strong> <br />
We hold no responsibility for people losing access to funds. Use at
your own risk!
</p>
</q-card-section>
</q-card>
</div>
{% endblock %} {% block scripts %}
<script>
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {}
}
})
</script>
{% endblock %}
</div>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,224 @@
from http import HTTPStatus
from fastapi import Request
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from . import cashu_ext, cashu_renderer
from .crud import get_cashu
templates = Jinja2Templates(directory="templates")
@cashu_ext.get("/", response_class=HTMLResponse)
async def index(
request: Request,
user: User = Depends(check_user_exists), # type: ignore
):
return cashu_renderer().TemplateResponse(
"cashu/index.html", {"request": request, "user": user.dict()}
)
@cashu_ext.get("/wallet")
async def wallet(request: Request, mint_id: str):
return cashu_renderer().TemplateResponse(
"cashu/wallet.html",
{
"request": request,
"web_manifest": f"/cashu/manifest/{mint_id}.webmanifest",
},
)
@cashu_ext.get("/mint/{mintID}")
async def cashu(request: Request, mintID):
cashu = await get_cashu(mintID)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
return cashu_renderer().TemplateResponse(
"cashu/mint.html",
{"request": request, "mint_name": cashu.name, "mint_id": mintID},
)
@cashu_ext.get("/manifest/{cashu_id}.webmanifest")
async def manifest(cashu_id: str):
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="TPoS does not exist."
)
return {
"short_name": "Cashu",
"name": "Cashu" + " - " + cashu.name,
"icons": [
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-512-512.png",
"type": "image/png",
"sizes": "512x512",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-96-96.png",
"type": "image/png",
"sizes": "96x96",
},
],
"id": "/cashu/wallet?mint_id=" + cashu_id,
"start_url": "/cashu/wallet?mint_id=" + cashu_id,
"background_color": "#1F2234",
"description": "Cashu ecash wallet",
"display": "standalone",
"scope": "/cashu/",
"theme_color": "#1F2234",
"protocol_handlers": [
{"protocol": "cashu", "url": "&recv_token=%s"},
{"protocol": "lightning", "url": "&lightning=%s"},
],
"shortcuts": [
{
"name": "Cashu" + " - " + cashu.name,
"short_name": "Cashu",
"description": "Cashu" + " - " + cashu.name,
"url": "/cashu/wallet?mint_id=" + cashu_id,
"icons": [
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-512-512.png",
"sizes": "512x512",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-192-192.png",
"sizes": "192x192",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-144-144.png",
"sizes": "144x144",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-96-96.png",
"sizes": "96x96",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-72-72.png",
"sizes": "72x72",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/android/android-launchericon-48-48.png",
"sizes": "48x48",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/16.png",
"sizes": "16x16",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/20.png",
"sizes": "20x20",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/29.png",
"sizes": "29x29",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/32.png",
"sizes": "32x32",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/40.png",
"sizes": "40x40",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/50.png",
"sizes": "50x50",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/57.png",
"sizes": "57x57",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/58.png",
"sizes": "58x58",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/60.png",
"sizes": "60x60",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/64.png",
"sizes": "64x64",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/72.png",
"sizes": "72x72",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/76.png",
"sizes": "76x76",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/80.png",
"sizes": "80x80",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/87.png",
"sizes": "87x87",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/100.png",
"sizes": "100x100",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/114.png",
"sizes": "114x114",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/120.png",
"sizes": "120x120",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/128.png",
"sizes": "128x128",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/144.png",
"sizes": "144x144",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/152.png",
"sizes": "152x152",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/167.png",
"sizes": "167x167",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/180.png",
"sizes": "180x180",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/192.png",
"sizes": "192x192",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/256.png",
"sizes": "256x256",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/512.png",
"sizes": "512x512",
},
{
"src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/ios/1024.png",
"sizes": "1024x1024",
},
],
}
],
}

View File

@ -0,0 +1,382 @@
import json
import math
from http import HTTPStatus
from typing import Dict, List, Union
import httpx
# -------- cashu imports
from cashu.core.base import (
BlindedSignature,
CheckFeesRequest,
CheckFeesResponse,
CheckRequest,
GetMeltResponse,
GetMintResponse,
Invoice,
MeltRequest,
MintRequest,
PostSplitResponse,
Proof,
SplitRequest,
)
from fastapi import Query
from fastapi.params import Depends
from lnurl import decode as decode_lnurl
from loguru import logger
from secp256k1 import PublicKey
from starlette.exceptions import HTTPException
from lnbits import bolt11
from lnbits.core.crud import check_internal, get_user
from lnbits.core.services import (
check_transaction_status,
create_invoice,
fee_reserve,
pay_invoice,
)
from lnbits.core.views.api import api_payment
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from lnbits.helpers import urlsafe_short_hash
from lnbits.wallets.base import PaymentStatus
from . import cashu_ext, ledger
from .crud import create_cashu, delete_cashu, get_cashu, get_cashus
from .models import Cashu
# --------- extension imports
LIGHTNING = True
########################################
############### LNBITS MINTS ###########
########################################
@cashu_ext.get("/api/v1/mints", status_code=HTTPStatus.OK)
async def api_cashus(
all_wallets: bool = Query(False), wallet: WalletTypeInfo = Depends(get_key_type) # type: ignore
):
"""
Get all mints of this wallet.
"""
wallet_ids = [wallet.wallet.id]
if all_wallets:
user = await get_user(wallet.wallet.user)
if user:
wallet_ids = user.wallet_ids
return [cashu.dict() for cashu in await get_cashus(wallet_ids)]
@cashu_ext.post("/api/v1/mints", status_code=HTTPStatus.CREATED)
async def api_cashu_create(
data: Cashu,
wallet: WalletTypeInfo = Depends(get_key_type), # type: ignore
):
"""
Create a new mint for this wallet.
"""
cashu_id = urlsafe_short_hash()
# generate a new keyset in cashu
keyset = await ledger.load_keyset(cashu_id)
cashu = await create_cashu(
cashu_id=cashu_id, keyset_id=keyset.id, wallet_id=wallet.wallet.id, data=data
)
logger.debug(cashu)
return cashu.dict()
@cashu_ext.delete("/api/v1/mints/{cashu_id}")
async def api_cashu_delete(
cashu_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) # type: ignore
):
"""
Delete an existing cashu mint.
"""
cashu = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Cashu mint does not exist."
)
if cashu.wallet != wallet.wallet.id:
raise HTTPException(
status_code=HTTPStatus.FORBIDDEN, detail="Not your Cashu mint."
)
await delete_cashu(cashu_id)
raise HTTPException(status_code=HTTPStatus.NO_CONTENT)
#######################################
########### CASHU ENDPOINTS ###########
#######################################
@cashu_ext.get("/api/v1/{cashu_id}/keys", status_code=HTTPStatus.OK)
async def keys(cashu_id: str = Query(None)) -> dict[int, str]:
"""Get the public keys of the mint"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return ledger.get_keyset(keyset_id=cashu.keyset_id)
@cashu_ext.get("/api/v1/{cashu_id}/keysets", status_code=HTTPStatus.OK)
async def keysets(cashu_id: str = Query(None)) -> dict[str, list[str]]:
"""Get the public keys of the mint"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return {"keysets": [cashu.keyset_id]}
@cashu_ext.get("/api/v1/{cashu_id}/mint")
async def request_mint(cashu_id: str = Query(None), amount: int = 0) -> GetMintResponse:
"""
Request minting of new tokens. The mint responds with a Lightning invoice.
This endpoint can be used for a Lightning invoice UX flow.
Call `POST /mint` after paying the invoice.
"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if not cashu:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
# create an invoice that the wallet needs to pay
try:
payment_hash, payment_request = await create_invoice(
wallet_id=cashu.wallet,
amount=amount,
memo=f"{cashu.name}",
extra={"tag": "cashu"},
)
invoice = Invoice(
amount=amount, pr=payment_request, hash=payment_hash, issued=False
)
# await store_lightning_invoice(cashu_id, invoice)
await ledger.crud.store_lightning_invoice(invoice=invoice, db=ledger.db)
except Exception as e:
logger.error(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
print(f"Lightning invoice: {payment_request}")
resp = GetMintResponse(pr=payment_request, hash=payment_hash)
# return {"pr": payment_request, "hash": payment_hash}
return resp
@cashu_ext.post("/api/v1/{cashu_id}/mint")
async def mint_coins(
data: MintRequest,
cashu_id: str = Query(None),
payment_hash: str = Query(None),
) -> List[BlindedSignature]:
"""
Requests the minting of tokens belonging to a paid payment request.
Call this endpoint after `GET /mint`.
"""
cashu: Union[Cashu, None] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
if LIGHTNING:
invoice: Invoice = await ledger.crud.get_lightning_invoice(
db=ledger.db, hash=payment_hash
)
if invoice is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Mint does not know this invoice.",
)
if invoice.issued == True:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail="Tokens already issued for this invoice.",
)
total_requested = sum([bm.amount for bm in data.blinded_messages])
if total_requested > invoice.amount:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED,
detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}",
)
status: PaymentStatus = await check_transaction_status(cashu.wallet, payment_hash)
if status.paid != True:
raise HTTPException(
status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid."
)
try:
keyset = ledger.keysets.keysets[cashu.keyset_id]
promises = await ledger._generate_promises(
B_s=data.blinded_messages, keyset=keyset
)
assert len(promises), HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="No promises returned."
)
await ledger.crud.update_lightning_invoice(
db=ledger.db, hash=payment_hash, issued=True
)
return promises
except Exception as e:
logger.error(e)
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
@cashu_ext.post("/api/v1/{cashu_id}/melt")
async def melt_coins(
payload: MeltRequest, cashu_id: str = Query(None)
) -> GetMeltResponse:
"""Invalidates proofs and pays a Lightning invoice."""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
proofs = payload.proofs
invoice = payload.invoice
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
# TOKENS
assert all([p.id == cashu.keyset_id for p in proofs]), HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
detail="Error: Tokens are from another mint.",
)
assert all([ledger._verify_proof(p) for p in proofs]), HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="Could not verify proofs.",
)
total_provided = sum([p["amount"] for p in proofs])
invoice_obj = bolt11.decode(invoice)
amount = math.ceil(invoice_obj.amount_msat / 1000)
internal_checking_id = await check_internal(invoice_obj.payment_hash)
if not internal_checking_id:
fees_msat = fee_reserve(invoice_obj.amount_msat)
else:
fees_msat = 0
assert total_provided >= amount + fees_msat / 1000, Exception(
f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)."
)
await pay_invoice(
wallet_id=cashu.wallet,
payment_request=invoice,
description=f"pay cashu invoice",
extra={"tag": "cashu", "cahsu_name": cashu.name},
)
status: PaymentStatus = await check_transaction_status(
cashu.wallet, invoice_obj.payment_hash
)
if status.paid == True:
await ledger._invalidate_proofs(proofs)
return GetMeltResponse(paid=status.paid, preimage=status.preimage)
@cashu_ext.post("/api/v1/{cashu_id}/check")
async def check_spendable(
payload: CheckRequest, cashu_id: str = Query(None)
) -> Dict[int, bool]:
"""Check whether a secret has been spent already or not."""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
return await ledger.check_spendable(payload.proofs)
@cashu_ext.post("/api/v1/{cashu_id}/checkfees")
async def check_fees(
payload: CheckFeesRequest, cashu_id: str = Query(None)
) -> CheckFeesResponse:
"""
Responds with the fees necessary to pay a Lightning invoice.
Used by wallets for figuring out the fees they need to supply.
This is can be useful for checking whether an invoice is internal (Cashu-to-Cashu).
"""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
invoice_obj = bolt11.decode(payload.pr)
internal_checking_id = await check_internal(invoice_obj.payment_hash)
if not internal_checking_id:
fees_msat = fee_reserve(invoice_obj.amount_msat)
else:
fees_msat = 0
return CheckFeesResponse(fee=fees_msat / 1000)
@cashu_ext.post("/api/v1/{cashu_id}/split")
async def split(
payload: SplitRequest, cashu_id: str = Query(None)
) -> PostSplitResponse:
"""
Requetst a set of tokens with amount "total" to be split into two
newly minted sets with amount "split" and "total-split".
"""
cashu: Union[None, Cashu] = await get_cashu(cashu_id)
if cashu is None:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist."
)
proofs = payload.proofs
# !!!!!!! MAKE SURE THAT PROOFS ARE ONLY FROM THIS CASHU KEYSET ID
# THIS IS NECESSARY BECAUSE THE CASHU BACKEND WILL ACCEPT ANY VALID
# TOKENS
if not all([p.id == cashu.keyset_id for p in proofs]):
raise HTTPException(
status_code=HTTPStatus.METHOD_NOT_ALLOWED,
detail="Error: Tokens are from another mint.",
)
amount = payload.amount
outputs = payload.outputs.blinded_messages
assert outputs, Exception("no outputs provided.")
split_return = None
try:
keyset = ledger.keysets.keysets[cashu.keyset_id]
split_return = await ledger.split(proofs, amount, outputs, keyset)
except Exception as exc:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(exc),
)
if not split_return:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="there was an error with the split",
)
frst_promises, scnd_promises = split_return
resp = PostSplitResponse(fst=frst_promises, snd=scnd_promises)
return resp

View File

@ -7,11 +7,11 @@ from starlette.exceptions import HTTPException
from lnbits.core import db as core_db
from lnbits.core.models import Payment
from lnbits.core.services import websocketUpdater
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_copilot
from .views import updater
async def wait_for_paid_invoices():
@ -65,9 +65,11 @@ async def on_invoice_paid(payment: Payment) -> None:
except (httpx.ConnectError, httpx.RequestError):
await mark_webhook_sent(payment, -1)
if payment.extra.get("comment"):
await updater(copilot.id, data, payment.extra.get("comment"))
await websocketUpdater(
copilot.id, str(data) + "-" + str(payment.extra.get("comment"))
)
await updater(copilot.id, data, "none")
await websocketUpdater(copilot.id, str(data) + "-none")
async def mark_webhook_sent(payment: Payment, status: int) -> None:

View File

@ -238,7 +238,7 @@
document.domain +
':' +
location.port +
'/copilot/ws/' +
'/api/v1/ws/' +
self.copilot.id
} else {
localUrl =
@ -246,7 +246,7 @@
document.domain +
':' +
location.port +
'/copilot/ws/' +
'/api/v1/ws/' +
self.copilot.id
}
this.connection = new WebSocket(localUrl)

View File

@ -35,48 +35,3 @@ async def panel(request: Request):
return copilot_renderer().TemplateResponse(
"copilot/panel.html", {"request": request}
)
##################WEBSOCKET ROUTES########################
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, copilot_id: str):
await websocket.accept()
websocket.id = copilot_id # type: ignore
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, copilot_id: str):
for connection in self.active_connections:
if connection.id == copilot_id: # type: ignore
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@copilot_ext.websocket("/ws/{copilot_id}", name="copilot.websocket_by_id")
async def websocket_endpoint(websocket: WebSocket, copilot_id: str):
await manager.connect(websocket, copilot_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(copilot_id, data, comment):
copilot = await get_copilot(copilot_id)
if not copilot:
return
await manager.send_personal_message(f"{data + '-' + comment}", copilot_id)

View File

@ -5,6 +5,7 @@ from fastapi.param_functions import Query
from fastapi.params import Depends
from starlette.exceptions import HTTPException
from lnbits.core.services import websocketUpdater
from lnbits.decorators import WalletTypeInfo, get_key_type, require_admin_key
from . import copilot_ext
@ -16,7 +17,6 @@ from .crud import (
update_copilot,
)
from .models import CreateCopilotData
from .views import updater
#######################COPILOT##########################
@ -92,7 +92,7 @@ async def api_copilot_ws_relay(
status_code=HTTPStatus.NOT_FOUND, detail="Copilot does not exist"
)
try:
await updater(copilot_id, data, comment)
await websocketUpdater(copilot_id, str(data) + "-" + str(comment))
except:
raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your copilot")
return ""

View File

@ -118,7 +118,7 @@
dense
v-model.trim="formDialog.data.company_name"
label="Company Name"
placeholder="LNBits Labs"
placeholder="LNbits Labs"
></q-input>
<q-input
filled

View File

@ -22,8 +22,8 @@
![redirect url](https://i.imgur.com/GMzl0lG.png)
- on Spotify click the "EDIT SETTINGS" button and paste the copied link in the _Redirect URI's_ prompt
![spotify app setting](https://i.imgur.com/vb0x4Tl.png)
- back on LNBits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
- choose on which device the LNBits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
- back on LNbits, click "AUTORIZE ACCESS" and "Agree" on the page that will open
- choose on which device the LNbits Jukebox extensions will stream to, you may have to be logged in in order to select the device (browser, smartphone app, etc...)
- and select what playlist will be available for users to choose songs (you need to have already playlist on Spotify)\
![select playlists](https://i.imgur.com/g4dbtED.png)

View File

@ -2,7 +2,7 @@
## Help DJ's and music producers conduct music livestreams
LNBits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
LNbits Livestream extension produces a static QR code that can be shown on screen while livestreaming a DJ set for example. If someone listening to the livestream likes a song and want to support the DJ and/or the producer he can scan the QR code with a LNURL-pay capable wallet.
When scanned, the QR code sends up information about the song playing at the moment (name and the producer of that song). Also, if the user likes the song and would like to support the producer, he can send a tip and a message for that specific track. If the user sends an amount over a specific threshold they will be given a link to download it (optional).
@ -25,7 +25,7 @@ The revenue will be sent to a wallet created specifically for that producer, wit
![adjust percentage](https://i.imgur.com/9weHKAB.jpg)
3. For every different producer added, when adding tracks, a wallet is generated for them\
![producer wallet](https://i.imgur.com/YFIZ7Tm.jpg)
4. On the bottom of the LNBits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
4. On the bottom of the LNbits DJ Livestream extension you'll find the static QR code ([LNURL-pay](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) you can add to the livestream or if you're a street performer you can print it and have it displayed
5. After all tracks and producers are added, you can start "playing" songs\
![play tracks](https://i.imgur.com/7ytiBkq.jpg)
6. You'll see the current track playing and a green icon indicating active track also\

View File

@ -3,4 +3,4 @@
Lndhub has nothing to do with lnd, it is just the name of the HTTP/JSON protocol https://bluewallet.io/ uses to talk to their Lightning custodian server at https://lndhub.io/.
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNBits joins the same club.
Despite not having been planned to this, Lndhub because somewhat a standard for custodian wallet communication when https://t.me/lntxbot and https://zeusln.app/ implemented the same interface. And with this extension LNbits joins the same club.

View File

@ -8,12 +8,11 @@ from fastapi import HTTPException
from lnbits import bolt11
from lnbits.core.models import Payment
from lnbits.core.services import pay_invoice
from lnbits.core.services import pay_invoice, websocketUpdater
from lnbits.helpers import get_current_extension_name
from lnbits.tasks import register_invoice_listener
from .crud import get_lnurldevice, get_lnurldevicepayment, update_lnurldevicepayment
from .views import updater
async def wait_for_paid_invoices():
@ -36,9 +35,8 @@ async def on_invoice_paid(payment: Payment) -> None:
lnurldevicepayment = await update_lnurldevicepayment(
lnurldevicepayment_id=payment.extra.get("id"), payhash="used"
)
return await updater(
return await websocketUpdater(
lnurldevicepayment.deviceid,
lnurldevicepayment.pin,
lnurldevicepayment.payload,
str(lnurldevicepayment.pin) + "-" + str(lnurldevicepayment.payload),
)
return

View File

@ -157,9 +157,9 @@
unelevated
color="primary"
size="md"
@click="copyText(wslocation + '/lnurldevice/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
>{% raw %}{{wslocation}}/lnurldevice/ws/{{settingsDialog.data.id}}{%
endraw %}<q-tooltip> Click to copy URL </q-tooltip>
@click="copyText(wslocation + '/api/v1/ws/' + settingsDialog.data.id, 'Link copied to clipboard!')"
>{% raw %}{{wslocation}}/api/v1/ws/{{settingsDialog.data.id}}{% endraw
%}<q-tooltip> Click to copy URL </q-tooltip>
</q-btn>
<q-btn
v-else
@ -657,7 +657,7 @@
lnurlValueFetch: function (lnurl, switchId) {
this.lnurlValue = lnurl
this.websocketConnector(
'wss://' + window.location.host + '/lnurldevice/ws/' + switchId
'wss://' + window.location.host + '/api/v1/ws/' + switchId
)
},
addSwitch: function () {

View File

@ -2,7 +2,7 @@ from http import HTTPStatus
from io import BytesIO
import pyqrcode
from fastapi import Request, WebSocket, WebSocketDisconnect
from fastapi import Request
from fastapi.param_functions import Query
from fastapi.params import Depends
from fastapi.templating import Jinja2Templates
@ -63,50 +63,3 @@ async def img(request: Request, lnurldevice_id):
status_code=HTTPStatus.NOT_FOUND, detail="LNURLDevice does not exist."
)
return lnurldevice.lnurl(request)
##################WEBSOCKET ROUTES########################
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket, lnurldevice_id: str):
await websocket.accept()
websocket.id = lnurldevice_id
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def send_personal_message(self, message: str, lnurldevice_id: str):
for connection in self.active_connections:
if connection.id == lnurldevice_id:
await connection.send_text(message)
async def broadcast(self, message: str):
for connection in self.active_connections:
await connection.send_text(message)
manager = ConnectionManager()
@lnurldevice_ext.websocket("/ws/{lnurldevice_id}", name="lnurldevice.lnurldevice_by_id")
async def websocket_endpoint(websocket: WebSocket, lnurldevice_id: str):
await manager.connect(websocket, lnurldevice_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket)
async def updater(lnurldevice_id, lnurldevice_pin, lnurldevice_amount):
lnurldevice = await get_lnurldevice(lnurldevice_id)
if not lnurldevice:
return
return await manager.send_personal_message(
f"{lnurldevice_pin}-{lnurldevice_amount}", lnurldevice_id
)

View File

@ -4,7 +4,7 @@
[![video tutorial offline shop](http://img.youtube.com/vi/_XAvM_LNsoo/0.jpg)](https://youtu.be/_XAvM_LNsoo 'video tutorial offline shop')
LNBits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
LNbits Offline Shop allows for merchants to receive Bitcoin payments while offline and without any electronic device.
Merchant will create items and associate a QR code ([a LNURLp](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/lnurlp/README.md)) with a price. He can then print the QR codes and display them on their shop. When a customer chooses an item, scans the QR code, gets the description and price. After payment, the customer gets a confirmation code that the merchant can validate to be sure the payment was successful.
@ -23,7 +23,7 @@ Customers must use an LNURL pay capable wallet.
![add new item](https://i.imgur.com/pkZqRgj.png)
3. After creating some products, click on "PRINT QR CODES"\
![print qr codes](https://i.imgur.com/2GAiSTe.png)
4. You'll see a QR code for each product in your LNBits Offline Shop with a title and price ready for printing\
4. You'll see a QR code for each product in your LNbits Offline Shop with a title and price ready for printing\
![qr codes sheet](https://i.imgur.com/faEqOcd.png)
5. Place the printed QR codes on your shop, or at the fair stall, or have them as a menu style laminated sheet
6. Choose what type of confirmation do you want customers to report to merchant after a successful payment\

View File

@ -23,5 +23,5 @@ Easilly create invoices that support Lightning Network and on-chain BTC payment.
![offchain payment](https://i.imgur.com/4191SMV.png)
- or pay on chain\
![onchain payment](https://i.imgur.com/wzLRR5N.png)
5. You can check the state of your charges in LNBits\
5. You can check the state of your charges in LNbits\
![invoice state](https://i.imgur.com/JnBd22p.png)

View File

@ -16,6 +16,7 @@ from .models import Charges, CreateCharge, SatsPayThemes
async def create_charge(user: str, data: CreateCharge) -> Charges:
data = CreateCharge(**data.dict())
charge_id = urlsafe_short_hash()
if data.onchainwallet:
config = await get_config(user)

View File

@ -6,6 +6,10 @@ from typing import Optional
from fastapi.param_functions import Query
from pydantic import BaseModel
DEFAULT_MEMPOOL_CONFIG = (
'{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}'
)
class CreateCharge(BaseModel):
onchainwallet: str = Query(None)
@ -17,7 +21,7 @@ class CreateCharge(BaseModel):
custom_css: Optional[str]
time: int = Query(..., ge=1)
amount: int = Query(..., ge=1)
extra: str = "{}"
extra: str = DEFAULT_MEMPOOL_CONFIG
class ChargeConfig(BaseModel):
@ -38,8 +42,8 @@ class Charges(BaseModel):
webhook: Optional[str]
completelink: Optional[str]
completelinktext: Optional[str] = "Back to Merchant"
extra: str = "{}"
custom_css: Optional[str]
extra: str = DEFAULT_MEMPOOL_CONFIG
time: int
amount: int
balance: int

View File

@ -2,7 +2,7 @@
## Have payments split between multiple wallets
LNBits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever.
LNbits Split Payments extension allows for distributing payments across multiple wallets. Set it and forget it. It will keep splitting your payments across wallets forever.
## Usage

View File

@ -19,7 +19,7 @@ So the goal of the extension is to allow the owner of a domain to sell subdomain
4. Get Cloudflare API TOKEN
<img src="https://i.imgur.com/BZbktTy.png">
<img src="https://i.imgur.com/YDZpW7D.png">
5. Open the LNBits subdomains extension and register your domain
5. Open the LNbits subdomains extension and register your domain
6. Click on the button in the table to open the public form that was generated for your domain
- Extension also supports webhooks so you can get notified when someone buys a new subdomain\

View File

@ -4,7 +4,7 @@
Monitor an extended public key and generate deterministic fresh public keys with this simple watch only wallet. Invoice payments can also be generated, both through a publically shareable page and API.
You can now use this wallet on the LNBits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
You can now use this wallet on the LNbits [SatsPayServer](https://github.com/lnbits/lnbits/blob/master/lnbits/extensions/satspay/README.md) extension
<a href="https://www.youtube.com/watch?v=rQMHzQEPwZY">Video demo</a>

View File

@ -203,7 +203,7 @@ async def update_address(id: str, **kwargs) -> Optional[Address]:
f"""UPDATE watchonly.addresses SET {q} WHERE id = ? """,
(*kwargs.values(), id),
)
row = await db.fetchone("SELECT * FROM watchonly.addresses WHERE id = ?", (id))
row = await db.fetchone("SELECT * FROM watchonly.addresses WHERE id = ?", (id,))
return Address.from_row(row) if row else None

View File

@ -76,9 +76,13 @@ class CreatePsbt(BaseModel):
tx_size: int
class SerializedTransaction(BaseModel):
tx_hex: str
class ExtractPsbt(BaseModel):
psbtBase64 = "" # // todo snake case
inputs: List[TransactionInput]
inputs: List[SerializedTransaction]
network = "Mainnet"
@ -87,10 +91,6 @@ class SignedTransaction(BaseModel):
tx_json: Optional[str]
class BroadcastTransaction(BaseModel):
tx_hex: str
class Config(BaseModel):
mempool_endpoint = "https://mempool.space"
receive_gap_limit = 20

View File

@ -272,15 +272,35 @@ async function payment(path) {
this.showChecking = false
}
},
fetchUtxoHexForPsbt: async function (psbtBase64) {
if (this.tx?.inputs && this.tx?.inputs.length) return this.tx.inputs
const {data: psbtUtxos} = await LNbits.api.request(
'PUT',
'/watchonly/api/v1/psbt/utxos',
this.adminkey,
{psbtBase64}
)
const inputs = []
for (const utxo of psbtUtxos) {
const txHex = await this.fetchTxHex(utxo.tx_id)
inputs.push({tx_hex: txHex})
}
return inputs
},
extractTxFromPsbt: async function (psbtBase64) {
try {
const inputs = await this.fetchUtxoHexForPsbt(psbtBase64)
const {data} = await LNbits.api.request(
'PUT',
'/watchonly/api/v1/psbt/extract',
this.adminkey,
{
psbtBase64,
inputs: this.tx.inputs,
inputs,
network: this.network
}
)

View File

@ -54,7 +54,10 @@ const watchOnly = async () => {
showPayment: false,
fetchedUtxos: false,
utxosFilter: '',
network: null
network: null,
showEnterSignedPsbt: false,
signedBase64Psbt: null
}
},
computed: {
@ -173,6 +176,15 @@ const watchOnly = async () => {
this.$refs.paymentRef.updateSignedPsbt(psbtBase64)
},
showEnterSignedPsbtDialog: function () {
this.signedBase64Psbt = ''
this.showEnterSignedPsbt = true
},
checkPsbt: function () {
this.$refs.paymentRef.updateSignedPsbt(this.signedBase64Psbt)
},
//################### UTXOs ###################
scanAllAddresses: async function () {
await this.refreshAddresses()

View File

@ -52,14 +52,38 @@
></q-spinner>
</div>
<div class="col-md-3 col-sm-5 q-pr-md">
<q-btn
<q-btn-dropdown
v-if="!showPayment"
split
unelevated
label="New Payment"
color="secondary"
class="btn-full"
@click="goToPaymentView"
>New Payment</q-btn
>
<q-list>
<q-item @click="goToPaymentView" clickable v-close-popup>
<q-item-section>
<q-item-label>New Payment</q-item-label>
<q-item-label caption
>Create a new payment by selecting Inputs and
Outputs</q-item-label
>
</q-item-section>
</q-item>
<q-item
@click="showEnterSignedPsbtDialog"
clickable
v-close-popup
>
<q-item-section>
<q-item-label>From Signed PSBT</q-item-label>
<q-item-label caption> Paste a signed PSBT</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
v-if="showPayment"
outline
@ -226,6 +250,36 @@
</q-card>
</q-dialog>
<q-dialog v-model="showEnterSignedPsbt" position="top">
<q-card class="q-pa-lg lnbits__dialog-card">
<h5 class="text-subtitle1 q-my-none">Enter the Signed PSBT</h5>
<q-separator></q-separator><br />
<p>
<q-input
filled
dense
v-model.trim="signedBase64Psbt"
type="textarea"
label="Signed PSBT"
></q-input>
</p>
<div class="row q-mt-lg q-gutter-sm">
<q-btn
outline
v-close-popup
color="grey"
@click="checkPsbt"
class="q-ml-sm"
>Check PSBT</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
<div class="row q-mt-lg q-gutter-sm"></div>
</q-card>
</q-dialog>
{% endraw %}
</div>

View File

@ -31,11 +31,11 @@ from .crud import (
)
from .helpers import parse_key
from .models import (
BroadcastTransaction,
Config,
CreatePsbt,
CreateWallet,
ExtractPsbt,
SerializedTransaction,
SignedTransaction,
WalletAccount,
)
@ -291,6 +291,24 @@ async def api_psbt_create(
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
@watchonly_ext.put("/api/v1/psbt/utxos")
async def api_psbt_extract_tx(
req: Request, w: WalletTypeInfo = Depends(require_admin_key)
):
"""Extract previous unspent transaction outputs (tx_id, vout) from PSBT"""
body = await req.json()
try:
psbt = PSBT.from_base64(body["psbtBase64"])
res = []
for _, inp in enumerate(psbt.inputs):
res.append({"tx_id": inp.txid.hex(), "vout": inp.vout})
return res
except Exception as e:
raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e))
@watchonly_ext.put("/api/v1/psbt/extract")
async def api_psbt_extract_tx(
data: ExtractPsbt, w: WalletTypeInfo = Depends(require_admin_key)
@ -327,7 +345,7 @@ async def api_psbt_extract_tx(
@watchonly_ext.post("/api/v1/tx")
async def api_tx_broadcast(
data: BroadcastTransaction, w: WalletTypeInfo = Depends(require_admin_key)
data: SerializedTransaction, w: WalletTypeInfo = Depends(require_admin_key)
):
try:
config = await get_config(w.wallet.user)

View File

@ -14,7 +14,7 @@ LNURL withdraw is a **very powerful tool** and should not have his use limited t
#### Quick Vouchers
LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc...
LNbits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes that you can print and distribute as rewards, onboarding people into Lightning Network, gifts, etc...
1. Create Quick Vouchers\
![quick vouchers](https://i.imgur.com/IUfwdQz.jpg)
@ -37,12 +37,12 @@ LNBits Quick Vouchers allows you to easily create a batch of LNURLw's QR codes t
- set a title for the LNURLw (it will show up in users wallet)
- define the minimum and maximum a user can withdraw, if you want a fixed amount set them both to an equal value
- set how many times can the LNURLw be scanned, if it's a one time use or it can be scanned 100 times
- LNBits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans
- LNbits has the "_Time between withdraws_" setting, you can define how long the LNURLw will be unavailable between scans
- you can set the time in _seconds, minutes or hours_
- the "_Use unique withdraw QR..._" reduces the chance of your LNURL withdraw being exploited and depleted by one person, by generating a new QR code every time it's scanned
2. Print, share or display your LNURLw link or it's QR code\
![lnurlw created](https://i.imgur.com/X00twiX.jpg)
**LNBits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNBits wallet!
**LNbits bonus:** If a user doesn't have a Lightning Network wallet and scans the LNURLw QR code with their smartphone camera, or a QR scanner app, they can follow the link provided to claim their satoshis and get an instant LNbits wallet!
![](https://i.imgur.com/2zZ7mi8.jpg)

View File

@ -20,6 +20,8 @@ class Extension(NamedTuple):
icon: Optional[str] = None
contributors: Optional[List[str]] = None
hidden: bool = False
migration_module: Optional[str] = None
db_name: Optional[str] = None
class ExtensionManager:
@ -66,6 +68,8 @@ class ExtensionManager:
config.get("icon"),
config.get("contributors"),
config.get("hidden") or False,
config.get("migration_module"),
config.get("db_name"),
)
)

View File

@ -11,7 +11,7 @@ from lnbits.settings import FORWARDED_ALLOW_IPS, HOST, PORT
)
)
@click.option("--port", default=PORT, help="Port to listen on")
@click.option("--host", default=HOST, help="Host to run LNBits on")
@click.option("--host", default=HOST, help="Host to run LNbits on")
@click.option(
"--forwarded-allow-ips", default=FORWARDED_ALLOW_IPS, help="Allowed proxy servers"
)

744
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -12,58 +12,60 @@ script = "build.py"
python = "^3.10 | ^3.9 | ^3.8 | ^3.7"
aiofiles = "0.8.0"
asgiref = "3.4.1"
attrs = "21.2.0"
attrs = "22.1.0"
bech32 = "1.2.0"
bitstring = "3.1.9"
certifi = "2021.5.30"
charset-normalizer = "2.0.6"
click = "8.0.1"
ecdsa = "0.17.0"
certifi = "2022.9.24"
charset-normalizer = "2.0.12"
click = "8.0.4"
ecdsa = "0.18.0"
embit = "0.4.9"
environs = "9.3.3"
fastapi = "0.78.0"
environs = "9.5.0"
fastapi = "0.83.0"
h11 = "0.12.0"
httpcore = "0.15.0"
httptools = "0.4.0"
httpx = "0.23.0"
idna = "3.2"
importlib-metadata = "4.8.1"
idna = "3.4"
importlib-metadata = "5.0.0"
jinja2 = "3.0.1"
lnurl = "0.3.6"
markupsafe = "2.0.1"
marshmallow = "3.17.0"
outcome = "1.1.0"
marshmallow = "3.18.0"
outcome = "1.2.0"
psycopg2-binary = "2.9.1"
pycryptodomex = "3.14.1"
pydantic = "1.8.2"
pydantic = "1.10.2"
pypng = "0.0.21"
pyqrcode = "1.2.1"
pyScss = "1.4.0"
python-dotenv = "0.19.0"
python-dotenv = "0.21.0"
pyyaml = "5.4.1"
represent = "1.6.0.post0"
rfc3986 = "1.5.0"
secp256k1 = "0.14.0"
shortuuid = "1.0.1"
six = "1.16.0"
sniffio = "1.2.0"
sqlalchemy = "1.3.23"
sniffio = "1.3.0"
sqlalchemy = "1.3.24"
sqlalchemy-aio = "0.17.0"
sse-starlette = "0.6.2"
typing-extensions = "3.10.0.2"
uvicorn = "0.18.1"
typing-extensions = "^4.4.0"
uvicorn = "0.18.3"
uvloop = "0.16.0"
watchgod = "0.7"
websockets = "10.0"
zipp = "3.5.0"
loguru = "0.5.3"
cffi = "1.15.0"
zipp = "3.9.0"
loguru = "0.6.0"
cffi = "1.15.1"
websocket-client = "1.3.3"
grpcio = "^1.49.1"
protobuf = "^4.21.6"
Cerberus = "^1.3.4"
async-timeout = "^4.0.2"
pyln-client = "0.11.1"
cashu = "0.5.5"
[tool.poetry.dev-dependencies]
isort = "^5.10.1"

View File

@ -1,45 +1,49 @@
aiofiles==0.8.0 ; python_version >= "3.7" and python_version < "4.0"
anyio==3.6.1 ; python_version >= "3.7" and python_version < "4.0"
anyio==3.6.2 ; python_version >= "3.7" and python_version < "4.0"
asgiref==3.4.1 ; python_version >= "3.7" and python_version < "4.0"
asn1crypto==1.5.1 ; python_version >= "3.7" and python_version < "4.0"
async-timeout==4.0.2 ; python_version >= "3.7" and python_version < "4.0"
attrs==21.2.0 ; python_version >= "3.7" and python_version < "4.0"
attrs==22.1.0 ; python_version >= "3.7" and python_version < "4.0"
base58==2.1.1 ; python_version >= "3.7" and python_version < "4.0"
bech32==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
bitstring==3.1.9 ; python_version >= "3.7" and python_version < "4.0"
cashu==0.5.4 ; python_version >= "3.7" and python_version < "4.0"
cerberus==1.3.4 ; python_version >= "3.7" and python_version < "4.0"
certifi==2021.5.30 ; python_version >= "3.7" and python_version < "4.0"
cffi==1.15.0 ; python_version >= "3.7" and python_version < "4.0"
charset-normalizer==2.0.6 ; python_version >= "3.7" and python_version < "4.0"
click==8.0.1 ; python_version >= "3.7" and python_version < "4.0"
certifi==2022.9.24 ; python_version >= "3.7" and python_version < "4.0"
cffi==1.15.1 ; python_version >= "3.7" and python_version < "4.0"
charset-normalizer==2.0.12 ; python_version >= "3.7" and python_version < "4.0"
click==8.0.4 ; python_version >= "3.7" and python_version < "4.0"
coincurve==17.0.0 ; python_version >= "3.7" and python_version < "4.0"
colorama==0.4.5 ; python_version >= "3.7" and python_version < "4.0" and platform_system == "Windows" or python_version >= "3.7" and python_version < "4.0" and sys_platform == "win32"
cryptography==36.0.2 ; python_version >= "3.7" and python_version < "4.0"
ecdsa==0.17.0 ; python_version >= "3.7" and python_version < "4.0"
ecdsa==0.18.0 ; python_version >= "3.7" and python_version < "4.0"
embit==0.4.9 ; python_version >= "3.7" and python_version < "4.0"
enum34==1.1.10 ; python_version >= "3.7" and python_version < "4.0"
environs==9.3.3 ; python_version >= "3.7" and python_version < "4.0"
fastapi==0.78.0 ; python_version >= "3.7" and python_version < "4.0"
grpcio==1.49.1 ; python_version >= "3.7" and python_version < "4.0"
environs==9.5.0 ; python_version >= "3.7" and python_version < "4.0"
fastapi==0.83.0 ; python_version >= "3.7" and python_version < "4.0"
grpcio==1.50.0 ; python_version >= "3.7" and python_version < "4.0"
h11==0.12.0 ; python_version >= "3.7" and python_version < "4.0"
httpcore==0.15.0 ; python_version >= "3.7" and python_version < "4.0"
httptools==0.4.0 ; python_version >= "3.7" and python_version < "4.0"
httpx==0.23.0 ; python_version >= "3.7" and python_version < "4.0"
idna==3.2 ; python_version >= "3.7" and python_version < "4.0"
importlib-metadata==4.8.1 ; python_version >= "3.7" and python_version < "4.0"
idna==3.4 ; python_version >= "3.7" and python_version < "4.0"
importlib-metadata==5.0.0 ; python_version >= "3.7" and python_version < "4.0"
iniconfig==1.1.1 ; python_version >= "3.7" and python_version < "4.0"
jinja2==3.0.1 ; python_version >= "3.7" and python_version < "4.0"
lnurl==0.3.6 ; python_version >= "3.7" and python_version < "4.0"
loguru==0.5.3 ; python_version >= "3.7" and python_version < "4.0"
loguru==0.6.0 ; python_version >= "3.7" and python_version < "4.0"
markupsafe==2.0.1 ; python_version >= "3.7" and python_version < "4.0"
marshmallow==3.17.0 ; python_version >= "3.7" and python_version < "4.0"
outcome==1.1.0 ; python_version >= "3.7" and python_version < "4.0"
marshmallow==3.18.0 ; python_version >= "3.7" and python_version < "4.0"
outcome==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
packaging==21.3 ; python_version >= "3.7" and python_version < "4.0"
pathlib2==2.3.7.post1 ; python_version >= "3.7" and python_version < "4.0"
protobuf==4.21.7 ; python_version >= "3.7" and python_version < "4.0"
pluggy==1.0.0 ; python_version >= "3.7" and python_version < "4.0"
protobuf==4.21.9 ; python_version >= "3.7" and python_version < "4.0"
psycopg2-binary==2.9.1 ; python_version >= "3.7" and python_version < "4.0"
py==1.11.0 ; python_version >= "3.7" and python_version < "4.0"
pycparser==2.21 ; python_version >= "3.7" and python_version < "4.0"
pycryptodomex==3.14.1 ; python_version >= "3.7" and python_version < "4.0"
pydantic==1.8.2 ; python_version >= "3.7" and python_version < "4.0"
pydantic==1.10.2 ; python_version >= "3.7" and python_version < "4.0"
pyln-bolt7==1.0.246 ; python_version >= "3.7" and python_version < "4.0"
pyln-client==0.11.1 ; python_version >= "3.7" and python_version < "4.0"
pyln-proto==0.11.1 ; python_version >= "3.7" and python_version < "4.0"
@ -48,25 +52,31 @@ pypng==0.0.21 ; python_version >= "3.7" and python_version < "4.0"
pyqrcode==1.2.1 ; python_version >= "3.7" and python_version < "4.0"
pyscss==1.4.0 ; python_version >= "3.7" and python_version < "4.0"
pysocks==1.7.1 ; python_version >= "3.7" and python_version < "4.0"
python-dotenv==0.19.0 ; python_version >= "3.7" and python_version < "4.0"
pytest-asyncio==0.19.0 ; python_version >= "3.7" and python_version < "4.0"
pytest==7.1.3 ; python_version >= "3.7" and python_version < "4.0"
python-bitcoinlib==0.11.2 ; python_version >= "3.7" and python_version < "4.0"
python-dotenv==0.21.0 ; python_version >= "3.7" and python_version < "4.0"
pyyaml==5.4.1 ; python_version >= "3.7" and python_version < "4.0"
represent==1.6.0.post0 ; python_version >= "3.7" and python_version < "4.0"
requests==2.27.1 ; python_version >= "3.7" and python_version < "4.0"
rfc3986==1.5.0 ; python_version >= "3.7" and python_version < "4.0"
rfc3986[idna2008]==1.5.0 ; python_version >= "3.7" and python_version < "4.0"
secp256k1==0.14.0 ; python_version >= "3.7" and python_version < "4.0"
setuptools==65.4.1 ; python_version >= "3.7" and python_version < "4.0"
setuptools==65.6.3 ; python_version >= "3.7" and python_version < "4.0"
shortuuid==1.0.1 ; python_version >= "3.7" and python_version < "4.0"
six==1.16.0 ; python_version >= "3.7" and python_version < "4.0"
sniffio==1.2.0 ; python_version >= "3.7" and python_version < "4.0"
sniffio==1.3.0 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy-aio==0.17.0 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy==1.3.23 ; python_version >= "3.7" and python_version < "4.0"
sqlalchemy==1.3.24 ; python_version >= "3.7" and python_version < "4.0"
sse-starlette==0.6.2 ; python_version >= "3.7" and python_version < "4.0"
starlette==0.19.1 ; python_version >= "3.7" and python_version < "4.0"
typing-extensions==3.10.0.2 ; python_version >= "3.7" and python_version < "4.0"
uvicorn==0.18.1 ; python_version >= "3.7" and python_version < "4.0"
tomli==2.0.1 ; python_version >= "3.7" and python_version < "4.0"
typing-extensions==4.4.0 ; python_version >= "3.7" and python_version < "4.0"
urllib3==1.26.12 ; python_version >= "3.7" and python_version < "4"
uvicorn==0.18.3 ; python_version >= "3.7" and python_version < "4.0"
uvloop==0.16.0 ; python_version >= "3.7" and python_version < "4.0"
watchgod==0.7 ; python_version >= "3.7" and python_version < "4.0"
websocket-client==1.3.3 ; python_version >= "3.7" and python_version < "4.0"
websockets==10.0 ; python_version >= "3.7" and python_version < "4.0"
win32-setctime==1.1.0 ; python_version >= "3.7" and python_version < "4.0" and sys_platform == "win32"
zipp==3.5.0 ; python_version >= "3.7" and python_version < "4.0"
zipp==3.9.0 ; python_version >= "3.7" and python_version < "4.0"

Binary file not shown.

View File

@ -22,7 +22,7 @@ async def accounting_invoice(invoices_wallet):
invoice_data = CreateInvoiceData(
status="open",
currency="USD",
company_name="LNBits, Inc",
company_name="LNbits, Inc",
first_name="Ben",
last_name="Arc",
items=[{"amount": 10.20, "description": "Item costs 10.20"}],

View File

@ -20,7 +20,7 @@ async def test_invoices_api_create_invoice_valid(client, invoices_wallet):
query = {
"status": "open",
"currency": "EUR",
"company_name": "LNBits, Inc.",
"company_name": "LNbits, Inc.",
"first_name": "Ben",
"last_name": "Arc",
"email": "ben@legend.arc",

View File

@ -133,6 +133,10 @@ def migrate_db(file: str, schema: str, exclude_tables: List[str] = []):
for table in tables:
tableName = table[0]
print(f"Migrating table {tableName}")
# hard coded skip for dbversions (already produced during startup)
if tableName == "dbversions":
continue
if tableName in exclude_tables:
continue
@ -156,7 +160,7 @@ def build_insert_query(schema, tableName, columns):
def to_column_type(columnType):
if columnType == "TIMESTAMP":
return "to_timestamp(%s)"
if columnType == "BOOLEAN":
if columnType in ["BOOLEAN", "BOOL"]:
return "%s::boolean"
return "%s"