diff --git a/.env.example b/.env.example index 32a67f75b..0a3f8f140 100644 --- a/.env.example +++ b/.env.example @@ -6,15 +6,16 @@ PORT=5000 DEBUG=false -# User IDs seperated by comma +# Allow users and admins by user IDs (comma separated list) +LNBITS_ALLOWED_USERS="" LNBITS_ADMIN_USERS="" # Extensions only admin can access -LNBITS_ADMIN_EXTENSIONS="ngrok, admin" +LNBITS_ADMIN_EXTENSIONS="ngrok, admin" # Enable Admin GUI, available for the first user in LNBITS_ADMIN_USERS if available -LNBITS_ADMIN_UI=false +LNBITS_ADMIN_UI=false # Restricts access, User IDs seperated by comma -LNBITS_ALLOWED_USERS="" +LNBITS_ALLOWED_USERS="" LNBITS_DEFAULT_WALLET_NAME="LNbits wallet" @@ -108,3 +109,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" diff --git a/lnbits/commands.py b/lnbits/commands.py index 830033144..95b767ecd 100644 --- a/lnbits/commands.py +++ b/lnbits/commands.py @@ -69,8 +69,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: @@ -101,20 +100,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.") diff --git a/lnbits/core/views/api.py b/lnbits/core/views/api.py index aab55b036..dc5fb89c5 100644 --- a/lnbits/core/views/api.py +++ b/lnbits/core/views/api.py @@ -12,7 +12,7 @@ from urllib.parse import ParseResult, parse_qs, urlencode, urlparse, urlunparse import async_timeout import httpx import pyqrcode -from fastapi import Depends, Header, Query, Request +from fastapi import Depends, Header, Query, Request, Response from fastapi.exceptions import HTTPException from fastapi.params import Body from loguru import logger @@ -584,8 +584,8 @@ class DecodePayment(BaseModel): data: str -@core_app.post("/api/v1/payments/decode") -async def api_payments_decode(data: DecodePayment): +@core_app.post("/api/v1/payments/decode", status_code=HTTPStatus.OK) +async def api_payments_decode(data: DecodePayment, response: Response): payment_str = data.data try: if payment_str[:5] == "LNURL": @@ -606,6 +606,7 @@ async def api_payments_decode(data: DecodePayment): "min_final_cltv_expiry": invoice.min_final_cltv_expiry, } except: + response.status_code = HTTPStatus.BAD_REQUEST return {"message": "Failed to decode"} diff --git a/lnbits/db.py b/lnbits/db.py index 8ae10f720..00bf849bb 100644 --- a/lnbits/db.py +++ b/lnbits/db.py @@ -1,6 +1,7 @@ import asyncio import datetime import os +import re import time from contextlib import asynccontextmanager from typing import Optional @@ -73,18 +74,40 @@ class Connection(Compat): query = query.replace("?", "%s") return query + def rewrite_values(self, values): + # strip html + CLEANR = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});") + + def cleanhtml(raw_html): + if isinstance(raw_html, str): + cleantext = re.sub(CLEANR, "", raw_html) + return cleantext + else: + return raw_html + + # tuple to list and back to tuple + value_list = [values] if isinstance(values, str) else list(values) + values = tuple([cleanhtml(l) for l in value_list]) + return values + async def fetchall(self, query: str, values: tuple = ()) -> list: - result = await self.conn.execute(self.rewrite_query(query), values) + result = await self.conn.execute( + self.rewrite_query(query), self.rewrite_values(values) + ) return await result.fetchall() async def fetchone(self, query: str, values: tuple = ()): - result = await self.conn.execute(self.rewrite_query(query), values) + result = await self.conn.execute( + self.rewrite_query(query), self.rewrite_values(values) + ) row = await result.fetchone() await result.close() return row async def execute(self, query: str, values: tuple = ()): - return await self.conn.execute(self.rewrite_query(query), values) + return await self.conn.execute( + self.rewrite_query(query), self.rewrite_values(values) + ) class Database(Compat): diff --git a/lnbits/extensions/cashu/README.md b/lnbits/extensions/cashu/README.md new file mode 100644 index 000000000..8f53b474b --- /dev/null +++ b/lnbits/extensions/cashu/README.md @@ -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 diff --git a/lnbits/extensions/cashu/__init__.py b/lnbits/extensions/cashu/__init__.py new file mode 100644 index 000000000..e6507bba9 --- /dev/null +++ b/lnbits/extensions/cashu/__init__.py @@ -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)) diff --git a/lnbits/extensions/cashu/config.json b/lnbits/extensions/cashu/config.json new file mode 100644 index 000000000..af202d43c --- /dev/null +++ b/lnbits/extensions/cashu/config.json @@ -0,0 +1,7 @@ +{ + "name": "Cashu", + "short_description": "Ecash mint and wallet", + "icon": "account_balance", + "contributors": ["calle", "vlad", "arcbtc"], + "hidden": false +} diff --git a/lnbits/extensions/cashu/crud.py b/lnbits/extensions/cashu/crud.py new file mode 100644 index 000000000..773a11fde --- /dev/null +++ b/lnbits/extensions/cashu/crud.py @@ -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,)) diff --git a/lnbits/extensions/cashu/migrations.py b/lnbits/extensions/cashu/migrations.py new file mode 100644 index 000000000..b54c41087 --- /dev/null +++ b/lnbits/extensions/cashu/migrations.py @@ -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 + ); + """ + ) diff --git a/lnbits/extensions/cashu/models.py b/lnbits/extensions/cashu/models.py new file mode 100644 index 000000000..c820d12ec --- /dev/null +++ b/lnbits/extensions/cashu/models.py @@ -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 diff --git a/lnbits/extensions/cashu/static/js/base64.js b/lnbits/extensions/cashu/static/js/base64.js new file mode 100644 index 000000000..b150882f7 --- /dev/null +++ b/lnbits/extensions/cashu/static/js/base64.js @@ -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 +})({}) diff --git a/lnbits/extensions/cashu/static/js/dhke.js b/lnbits/extensions/cashu/static/js/dhke.js new file mode 100644 index 000000000..41c2fb46c --- /dev/null +++ b/lnbits/extensions/cashu/static/js/dhke.js @@ -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 +} diff --git a/lnbits/extensions/cashu/static/js/noble-secp256k1.js b/lnbits/extensions/cashu/static/js/noble-secp256k1.js new file mode 100644 index 000000000..6a6bd4417 --- /dev/null +++ b/lnbits/extensions/cashu/static/js/noble-secp256k1.js @@ -0,0 +1,1178 @@ +;(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' + ? factory(exports) + : typeof define === 'function' && define.amd + ? define(['exports'], factory) + : ((global = + typeof globalThis !== 'undefined' ? globalThis : global || self), + factory((global.nobleSecp256k1 = {}))) +})(this, function (exports) { + 'use strict' + + const _nodeResolve_empty = {} + + const nodeCrypto = /*#__PURE__*/ Object.freeze({ + __proto__: null, + default: _nodeResolve_empty + }) + + /*! noble-secp256k1 - MIT License (c) 2019 Paul Miller (paulmillr.com) */ + const _0n = BigInt(0) + const _1n = BigInt(1) + const _2n = BigInt(2) + const _3n = BigInt(3) + const _8n = BigInt(8) + const POW_2_256 = _2n ** BigInt(256) + const CURVE = { + a: _0n, + b: BigInt(7), + P: POW_2_256 - _2n ** BigInt(32) - BigInt(977), + n: POW_2_256 - BigInt('432420386565659656852420866394968145599'), + h: _1n, + Gx: BigInt( + '55066263022277343669578718895168534326250603453777594175500187360389116729240' + ), + Gy: BigInt( + '32670510020758816978083085130507043184471273380659243275938904335757337482424' + ), + beta: BigInt( + '0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee' + ) + } + function weistrass(x) { + const {a, b} = CURVE + const x2 = mod(x * x) + const x3 = mod(x2 * x) + return mod(x3 + a * x + b) + } + const USE_ENDOMORPHISM = CURVE.a === _0n + class JacobianPoint { + constructor(x, y, z) { + this.x = x + this.y = y + this.z = z + } + static fromAffine(p) { + if (!(p instanceof Point)) { + throw new TypeError('JacobianPoint#fromAffine: expected Point') + } + return new JacobianPoint(p.x, p.y, _1n) + } + static toAffineBatch(points) { + const toInv = invertBatch(points.map(p => p.z)) + return points.map((p, i) => p.toAffine(toInv[i])) + } + static normalizeZ(points) { + return JacobianPoint.toAffineBatch(points).map(JacobianPoint.fromAffine) + } + equals(other) { + if (!(other instanceof JacobianPoint)) + throw new TypeError('JacobianPoint expected') + const {x: X1, y: Y1, z: Z1} = this + const {x: X2, y: Y2, z: Z2} = other + const Z1Z1 = mod(Z1 ** _2n) + const Z2Z2 = mod(Z2 ** _2n) + const U1 = mod(X1 * Z2Z2) + const U2 = mod(X2 * Z1Z1) + const S1 = mod(mod(Y1 * Z2) * Z2Z2) + const S2 = mod(mod(Y2 * Z1) * Z1Z1) + return U1 === U2 && S1 === S2 + } + negate() { + return new JacobianPoint(this.x, mod(-this.y), this.z) + } + double() { + const {x: X1, y: Y1, z: Z1} = this + const A = mod(X1 ** _2n) + const B = mod(Y1 ** _2n) + const C = mod(B ** _2n) + const D = mod(_2n * (mod((X1 + B) ** _2n) - A - C)) + const E = mod(_3n * A) + const F = mod(E ** _2n) + const X3 = mod(F - _2n * D) + const Y3 = mod(E * (D - X3) - _8n * C) + const Z3 = mod(_2n * Y1 * Z1) + return new JacobianPoint(X3, Y3, Z3) + } + add(other) { + if (!(other instanceof JacobianPoint)) + throw new TypeError('JacobianPoint expected') + const {x: X1, y: Y1, z: Z1} = this + const {x: X2, y: Y2, z: Z2} = other + if (X2 === _0n || Y2 === _0n) return this + if (X1 === _0n || Y1 === _0n) return other + const Z1Z1 = mod(Z1 ** _2n) + const Z2Z2 = mod(Z2 ** _2n) + const U1 = mod(X1 * Z2Z2) + const U2 = mod(X2 * Z1Z1) + const S1 = mod(mod(Y1 * Z2) * Z2Z2) + const S2 = mod(mod(Y2 * Z1) * Z1Z1) + const H = mod(U2 - U1) + const r = mod(S2 - S1) + if (H === _0n) { + if (r === _0n) { + return this.double() + } else { + return JacobianPoint.ZERO + } + } + const HH = mod(H ** _2n) + const HHH = mod(H * HH) + const V = mod(U1 * HH) + const X3 = mod(r ** _2n - HHH - _2n * V) + const Y3 = mod(r * (V - X3) - S1 * HHH) + const Z3 = mod(Z1 * Z2 * H) + return new JacobianPoint(X3, Y3, Z3) + } + subtract(other) { + return this.add(other.negate()) + } + multiplyUnsafe(scalar) { + const P0 = JacobianPoint.ZERO + if (typeof scalar === 'bigint' && scalar === _0n) return P0 + let n = normalizeScalar(scalar) + if (n === _1n) return this + if (!USE_ENDOMORPHISM) { + let p = P0 + let d = this + while (n > _0n) { + if (n & _1n) p = p.add(d) + d = d.double() + n >>= _1n + } + return p + } + let {k1neg, k1, k2neg, k2} = splitScalarEndo(n) + let k1p = P0 + let k2p = P0 + let d = this + while (k1 > _0n || k2 > _0n) { + if (k1 & _1n) k1p = k1p.add(d) + if (k2 & _1n) k2p = k2p.add(d) + d = d.double() + k1 >>= _1n + k2 >>= _1n + } + if (k1neg) k1p = k1p.negate() + if (k2neg) k2p = k2p.negate() + k2p = new JacobianPoint(mod(k2p.x * CURVE.beta), k2p.y, k2p.z) + return k1p.add(k2p) + } + precomputeWindow(W) { + const windows = USE_ENDOMORPHISM ? 128 / W + 1 : 256 / W + 1 + const points = [] + let p = this + let base = p + for (let window = 0; window < windows; window++) { + base = p + points.push(base) + for (let i = 1; i < 2 ** (W - 1); i++) { + base = base.add(p) + points.push(base) + } + p = base.double() + } + return points + } + wNAF(n, affinePoint) { + if (!affinePoint && this.equals(JacobianPoint.BASE)) + affinePoint = Point.BASE + const W = (affinePoint && affinePoint._WINDOW_SIZE) || 1 + if (256 % W) { + throw new Error( + 'Point#wNAF: Invalid precomputation window, must be power of 2' + ) + } + let precomputes = affinePoint && pointPrecomputes.get(affinePoint) + if (!precomputes) { + precomputes = this.precomputeWindow(W) + if (affinePoint && W !== 1) { + precomputes = JacobianPoint.normalizeZ(precomputes) + pointPrecomputes.set(affinePoint, precomputes) + } + } + let p = JacobianPoint.ZERO + let f = JacobianPoint.ZERO + const windows = 1 + (USE_ENDOMORPHISM ? 128 / W : 256 / W) + const windowSize = 2 ** (W - 1) + const mask = BigInt(2 ** W - 1) + const maxNumber = 2 ** W + const shiftBy = BigInt(W) + for (let window = 0; window < windows; window++) { + const offset = window * windowSize + let wbits = Number(n & mask) + n >>= shiftBy + if (wbits > windowSize) { + wbits -= maxNumber + n += _1n + } + if (wbits === 0) { + let pr = precomputes[offset] + if (window % 2) pr = pr.negate() + f = f.add(pr) + } else { + let cached = precomputes[offset + Math.abs(wbits) - 1] + if (wbits < 0) cached = cached.negate() + p = p.add(cached) + } + } + return {p, f} + } + multiply(scalar, affinePoint) { + let n = normalizeScalar(scalar) + let point + let fake + if (USE_ENDOMORPHISM) { + const {k1neg, k1, k2neg, k2} = splitScalarEndo(n) + let {p: k1p, f: f1p} = this.wNAF(k1, affinePoint) + let {p: k2p, f: f2p} = this.wNAF(k2, affinePoint) + if (k1neg) k1p = k1p.negate() + if (k2neg) k2p = k2p.negate() + k2p = new JacobianPoint(mod(k2p.x * CURVE.beta), k2p.y, k2p.z) + point = k1p.add(k2p) + fake = f1p.add(f2p) + } else { + const {p, f} = this.wNAF(n, affinePoint) + point = p + fake = f + } + return JacobianPoint.normalizeZ([point, fake])[0] + } + toAffine(invZ = invert(this.z)) { + const {x, y, z} = this + const iz1 = invZ + const iz2 = mod(iz1 * iz1) + const iz3 = mod(iz2 * iz1) + const ax = mod(x * iz2) + const ay = mod(y * iz3) + const zz = mod(z * iz1) + if (zz !== _1n) throw new Error('invZ was invalid') + return new Point(ax, ay) + } + } + JacobianPoint.BASE = new JacobianPoint(CURVE.Gx, CURVE.Gy, _1n) + JacobianPoint.ZERO = new JacobianPoint(_0n, _1n, _0n) + const pointPrecomputes = new WeakMap() + class Point { + constructor(x, y) { + this.x = x + this.y = y + } + _setWindowSize(windowSize) { + this._WINDOW_SIZE = windowSize + pointPrecomputes.delete(this) + } + static fromCompressedHex(bytes) { + const isShort = bytes.length === 32 + const x = bytesToNumber(isShort ? bytes : bytes.subarray(1)) + if (!isValidFieldElement(x)) throw new Error('Point is not on curve') + const y2 = weistrass(x) + let y = sqrtMod(y2) + const isYOdd = (y & _1n) === _1n + if (isShort) { + if (isYOdd) y = mod(-y) + } else { + const isFirstByteOdd = (bytes[0] & 1) === 1 + if (isFirstByteOdd !== isYOdd) y = mod(-y) + } + const point = new Point(x, y) + point.assertValidity() + return point + } + static fromUncompressedHex(bytes) { + const x = bytesToNumber(bytes.subarray(1, 33)) + const y = bytesToNumber(bytes.subarray(33, 65)) + const point = new Point(x, y) + point.assertValidity() + return point + } + static fromHex(hex) { + const bytes = ensureBytes(hex) + const len = bytes.length + const header = bytes[0] + if (len === 32 || (len === 33 && (header === 0x02 || header === 0x03))) { + return this.fromCompressedHex(bytes) + } + if (len === 65 && header === 0x04) return this.fromUncompressedHex(bytes) + throw new Error( + `Point.fromHex: received invalid point. Expected 32-33 compressed bytes or 65 uncompressed bytes, not ${len}` + ) + } + static fromPrivateKey(privateKey) { + return Point.BASE.multiply(normalizePrivateKey(privateKey)) + } + static fromSignature(msgHash, signature, recovery) { + msgHash = ensureBytes(msgHash) + const h = truncateHash(msgHash) + const {r, s} = normalizeSignature(signature) + if (recovery !== 0 && recovery !== 1) { + throw new Error('Cannot recover signature: invalid recovery bit') + } + const prefix = recovery & 1 ? '03' : '02' + const R = Point.fromHex(prefix + numTo32bStr(r)) + const {n} = CURVE + const rinv = invert(r, n) + const u1 = mod(-h * rinv, n) + const u2 = mod(s * rinv, n) + const Q = Point.BASE.multiplyAndAddUnsafe(R, u1, u2) + if (!Q) throw new Error('Cannot recover signature: point at infinify') + Q.assertValidity() + return Q + } + toRawBytes(isCompressed = false) { + return hexToBytes(this.toHex(isCompressed)) + } + toHex(isCompressed = false) { + const x = numTo32bStr(this.x) + if (isCompressed) { + const prefix = this.y & _1n ? '03' : '02' + return `${prefix}${x}` + } else { + return `04${x}${numTo32bStr(this.y)}` + } + } + toHexX() { + return this.toHex(true).slice(2) + } + toRawX() { + return this.toRawBytes(true).slice(1) + } + assertValidity() { + const msg = 'Point is not on elliptic curve' + const {x, y} = this + if (!isValidFieldElement(x) || !isValidFieldElement(y)) + throw new Error(msg) + const left = mod(y * y) + const right = weistrass(x) + if (mod(left - right) !== _0n) throw new Error(msg) + } + equals(other) { + return this.x === other.x && this.y === other.y + } + negate() { + return new Point(this.x, mod(-this.y)) + } + double() { + return JacobianPoint.fromAffine(this).double().toAffine() + } + add(other) { + return JacobianPoint.fromAffine(this) + .add(JacobianPoint.fromAffine(other)) + .toAffine() + } + subtract(other) { + return this.add(other.negate()) + } + multiply(scalar) { + return JacobianPoint.fromAffine(this).multiply(scalar, this).toAffine() + } + multiplyAndAddUnsafe(Q, a, b) { + const P = JacobianPoint.fromAffine(this) + const aP = + a === _0n || a === _1n || this !== Point.BASE + ? P.multiplyUnsafe(a) + : P.multiply(a) + const bQ = JacobianPoint.fromAffine(Q).multiplyUnsafe(b) + const sum = aP.add(bQ) + return sum.equals(JacobianPoint.ZERO) ? undefined : sum.toAffine() + } + } + Point.BASE = new Point(CURVE.Gx, CURVE.Gy) + Point.ZERO = new Point(_0n, _0n) + function sliceDER(s) { + return Number.parseInt(s[0], 16) >= 8 ? '00' + s : s + } + function parseDERInt(data) { + if (data.length < 2 || data[0] !== 0x02) { + throw new Error(`Invalid signature integer tag: ${bytesToHex(data)}`) + } + const len = data[1] + const res = data.subarray(2, len + 2) + if (!len || res.length !== len) { + throw new Error(`Invalid signature integer: wrong length`) + } + if (res[0] === 0x00 && res[1] <= 0x7f) { + throw new Error('Invalid signature integer: trailing length') + } + return {data: bytesToNumber(res), left: data.subarray(len + 2)} + } + function parseDERSignature(data) { + if (data.length < 2 || data[0] != 0x30) { + throw new Error(`Invalid signature tag: ${bytesToHex(data)}`) + } + if (data[1] !== data.length - 2) { + throw new Error('Invalid signature: incorrect length') + } + const {data: r, left: sBytes} = parseDERInt(data.subarray(2)) + const {data: s, left: rBytesLeft} = parseDERInt(sBytes) + if (rBytesLeft.length) { + throw new Error( + `Invalid signature: left bytes after parsing: ${bytesToHex(rBytesLeft)}` + ) + } + return {r, s} + } + class Signature { + constructor(r, s) { + this.r = r + this.s = s + this.assertValidity() + } + static fromCompact(hex) { + const arr = isUint8a(hex) + const name = 'Signature.fromCompact' + if (typeof hex !== 'string' && !arr) + throw new TypeError(`${name}: Expected string or Uint8Array`) + const str = arr ? bytesToHex(hex) : hex + if (str.length !== 128) throw new Error(`${name}: Expected 64-byte hex`) + return new Signature( + hexToNumber(str.slice(0, 64)), + hexToNumber(str.slice(64, 128)) + ) + } + static fromDER(hex) { + const arr = isUint8a(hex) + if (typeof hex !== 'string' && !arr) + throw new TypeError(`Signature.fromDER: Expected string or Uint8Array`) + const {r, s} = parseDERSignature(arr ? hex : hexToBytes(hex)) + return new Signature(r, s) + } + static fromHex(hex) { + return this.fromDER(hex) + } + assertValidity() { + const {r, s} = this + if (!isWithinCurveOrder(r)) + throw new Error('Invalid Signature: r must be 0 < r < n') + if (!isWithinCurveOrder(s)) + throw new Error('Invalid Signature: s must be 0 < s < n') + } + hasHighS() { + const HALF = CURVE.n >> _1n + return this.s > HALF + } + normalizeS() { + return this.hasHighS() ? new Signature(this.r, CURVE.n - this.s) : this + } + toDERRawBytes(isCompressed = false) { + return hexToBytes(this.toDERHex(isCompressed)) + } + toDERHex(isCompressed = false) { + const sHex = sliceDER(numberToHexUnpadded(this.s)) + if (isCompressed) return sHex + const rHex = sliceDER(numberToHexUnpadded(this.r)) + const rLen = numberToHexUnpadded(rHex.length / 2) + const sLen = numberToHexUnpadded(sHex.length / 2) + const length = numberToHexUnpadded(rHex.length / 2 + sHex.length / 2 + 4) + return `30${length}02${rLen}${rHex}02${sLen}${sHex}` + } + toRawBytes() { + return this.toDERRawBytes() + } + toHex() { + return this.toDERHex() + } + toCompactRawBytes() { + return hexToBytes(this.toCompactHex()) + } + toCompactHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s) + } + } + function concatBytes(...arrays) { + if (!arrays.every(isUint8a)) throw new Error('Uint8Array list expected') + if (arrays.length === 1) return arrays[0] + const length = arrays.reduce((a, arr) => a + arr.length, 0) + const result = new Uint8Array(length) + for (let i = 0, pad = 0; i < arrays.length; i++) { + const arr = arrays[i] + result.set(arr, pad) + pad += arr.length + } + return result + } + function isUint8a(bytes) { + return bytes instanceof Uint8Array + } + const hexes = Array.from({length: 256}, (v, i) => + i.toString(16).padStart(2, '0') + ) + function bytesToHex(uint8a) { + if (!(uint8a instanceof Uint8Array)) throw new Error('Expected Uint8Array') + let hex = '' + for (let i = 0; i < uint8a.length; i++) { + hex += hexes[uint8a[i]] + } + return hex + } + function numTo32bStr(num) { + if (num > POW_2_256) throw new Error('Expected number < 2^256') + return num.toString(16).padStart(64, '0') + } + function numTo32b(num) { + return hexToBytes(numTo32bStr(num)) + } + function numberToHexUnpadded(num) { + const hex = num.toString(16) + return hex.length & 1 ? `0${hex}` : hex + } + function hexToNumber(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToNumber: expected string, got ' + typeof hex) + } + return BigInt(`0x${hex}`) + } + function hexToBytes(hex) { + if (typeof hex !== 'string') { + throw new TypeError('hexToBytes: expected string, got ' + typeof hex) + } + if (hex.length % 2) + throw new Error('hexToBytes: received invalid unpadded hex' + hex.length) + const array = new Uint8Array(hex.length / 2) + for (let i = 0; i < array.length; i++) { + const j = i * 2 + const hexByte = hex.slice(j, j + 2) + const byte = Number.parseInt(hexByte, 16) + if (Number.isNaN(byte) || byte < 0) + throw new Error('Invalid byte sequence') + array[i] = byte + } + return array + } + function bytesToNumber(bytes) { + return hexToNumber(bytesToHex(bytes)) + } + function ensureBytes(hex) { + return hex instanceof Uint8Array ? Uint8Array.from(hex) : hexToBytes(hex) + } + function normalizeScalar(num) { + if (typeof num === 'number' && Number.isSafeInteger(num) && num > 0) + return BigInt(num) + if (typeof num === 'bigint' && isWithinCurveOrder(num)) return num + throw new TypeError('Expected valid private scalar: 0 < scalar < curve.n') + } + function mod(a, b = CURVE.P) { + const result = a % b + return result >= _0n ? result : b + result + } + function pow2(x, power) { + const {P} = CURVE + let res = x + while (power-- > _0n) { + res *= res + res %= P + } + return res + } + function sqrtMod(x) { + const {P} = CURVE + const _6n = BigInt(6) + const _11n = BigInt(11) + const _22n = BigInt(22) + const _23n = BigInt(23) + const _44n = BigInt(44) + const _88n = BigInt(88) + const b2 = (x * x * x) % P + const b3 = (b2 * b2 * x) % P + const b6 = (pow2(b3, _3n) * b3) % P + const b9 = (pow2(b6, _3n) * b3) % P + const b11 = (pow2(b9, _2n) * b2) % P + const b22 = (pow2(b11, _11n) * b11) % P + const b44 = (pow2(b22, _22n) * b22) % P + const b88 = (pow2(b44, _44n) * b44) % P + const b176 = (pow2(b88, _88n) * b88) % P + const b220 = (pow2(b176, _44n) * b44) % P + const b223 = (pow2(b220, _3n) * b3) % P + const t1 = (pow2(b223, _23n) * b22) % P + const t2 = (pow2(t1, _6n) * b2) % P + return pow2(t2, _2n) + } + function invert(number, modulo = CURVE.P) { + if (number === _0n || modulo <= _0n) { + throw new Error( + `invert: expected positive integers, got n=${number} mod=${modulo}` + ) + } + let a = mod(number, modulo) + let b = modulo + let x = _0n, + u = _1n + while (a !== _0n) { + const q = b / a + const r = b % a + const m = x - u * q + ;(b = a), (a = r), (x = u), (u = m) + } + const gcd = b + if (gcd !== _1n) throw new Error('invert: does not exist') + return mod(x, modulo) + } + function invertBatch(nums, p = CURVE.P) { + const scratch = new Array(nums.length) + const lastMultiplied = nums.reduce((acc, num, i) => { + if (num === _0n) return acc + scratch[i] = acc + return mod(acc * num, p) + }, _1n) + const inverted = invert(lastMultiplied, p) + nums.reduceRight((acc, num, i) => { + if (num === _0n) return acc + scratch[i] = mod(acc * scratch[i], p) + return mod(acc * num, p) + }, inverted) + return scratch + } + const divNearest = (a, b) => (a + b / _2n) / b + const POW_2_128 = _2n ** BigInt(128) + function splitScalarEndo(k) { + const {n} = CURVE + const a1 = BigInt('0x3086d221a7d46bcde86c90e49284eb15') + const b1 = -_1n * BigInt('0xe4437ed6010e88286f547fa90abfe4c3') + const a2 = BigInt('0x114ca50f7a8e2f3f657c1108d9d44cfd8') + const b2 = a1 + const c1 = divNearest(b2 * k, n) + const c2 = divNearest(-b1 * k, n) + let k1 = mod(k - c1 * a1 - c2 * a2, n) + let k2 = mod(-c1 * b1 - c2 * b2, n) + const k1neg = k1 > POW_2_128 + const k2neg = k2 > POW_2_128 + if (k1neg) k1 = n - k1 + if (k2neg) k2 = n - k2 + if (k1 > POW_2_128 || k2 > POW_2_128) { + throw new Error('splitScalarEndo: Endomorphism failed, k=' + k) + } + return {k1neg, k1, k2neg, k2} + } + function truncateHash(hash) { + const {n} = CURVE + const byteLength = hash.length + const delta = byteLength * 8 - 256 + let h = bytesToNumber(hash) + if (delta > 0) h = h >> BigInt(delta) + if (h >= n) h -= n + return h + } + class HmacDrbg { + constructor() { + this.v = new Uint8Array(32).fill(1) + this.k = new Uint8Array(32).fill(0) + this.counter = 0 + } + hmac(...values) { + return utils.hmacSha256(this.k, ...values) + } + hmacSync(...values) { + if (typeof utils.hmacSha256Sync !== 'function') + throw new Error('utils.hmacSha256Sync is undefined, you need to set it') + const res = utils.hmacSha256Sync(this.k, ...values) + if (res instanceof Promise) + throw new Error('To use sync sign(), ensure utils.hmacSha256 is sync') + return res + } + incr() { + if (this.counter >= 1000) { + throw new Error('Tried 1,000 k values for sign(), all were invalid') + } + this.counter += 1 + } + async reseed(seed = new Uint8Array()) { + this.k = await this.hmac(this.v, Uint8Array.from([0x00]), seed) + this.v = await this.hmac(this.v) + if (seed.length === 0) return + this.k = await this.hmac(this.v, Uint8Array.from([0x01]), seed) + this.v = await this.hmac(this.v) + } + reseedSync(seed = new Uint8Array()) { + this.k = this.hmacSync(this.v, Uint8Array.from([0x00]), seed) + this.v = this.hmacSync(this.v) + if (seed.length === 0) return + this.k = this.hmacSync(this.v, Uint8Array.from([0x01]), seed) + this.v = this.hmacSync(this.v) + } + async generate() { + this.incr() + this.v = await this.hmac(this.v) + return this.v + } + generateSync() { + this.incr() + this.v = this.hmacSync(this.v) + return this.v + } + } + function isWithinCurveOrder(num) { + return _0n < num && num < CURVE.n + } + function isValidFieldElement(num) { + return _0n < num && num < CURVE.P + } + function kmdToSig(kBytes, m, d) { + const k = bytesToNumber(kBytes) + if (!isWithinCurveOrder(k)) return + const {n} = CURVE + const q = Point.BASE.multiply(k) + const r = mod(q.x, n) + if (r === _0n) return + const s = mod(invert(k, n) * mod(m + d * r, n), n) + if (s === _0n) return + const sig = new Signature(r, s) + const recovery = (q.x === sig.r ? 0 : 2) | Number(q.y & _1n) + return {sig, recovery} + } + function normalizePrivateKey(key) { + let num + if (typeof key === 'bigint') { + num = key + } else if ( + typeof key === 'number' && + Number.isSafeInteger(key) && + key > 0 + ) { + num = BigInt(key) + } else if (typeof key === 'string') { + if (key.length !== 64) throw new Error('Expected 32 bytes of private key') + num = hexToNumber(key) + } else if (isUint8a(key)) { + if (key.length !== 32) throw new Error('Expected 32 bytes of private key') + num = bytesToNumber(key) + } else { + throw new TypeError('Expected valid private key') + } + if (!isWithinCurveOrder(num)) + throw new Error('Expected private key: 0 < key < n') + return num + } + function normalizePublicKey(publicKey) { + if (publicKey instanceof Point) { + publicKey.assertValidity() + return publicKey + } else { + return Point.fromHex(publicKey) + } + } + function normalizeSignature(signature) { + if (signature instanceof Signature) { + signature.assertValidity() + return signature + } + try { + return Signature.fromDER(signature) + } catch (error) { + return Signature.fromCompact(signature) + } + } + function getPublicKey(privateKey, isCompressed = false) { + return Point.fromPrivateKey(privateKey).toRawBytes(isCompressed) + } + function recoverPublicKey( + msgHash, + signature, + recovery, + isCompressed = false + ) { + return Point.fromSignature(msgHash, signature, recovery).toRawBytes( + isCompressed + ) + } + function isPub(item) { + const arr = isUint8a(item) + const str = typeof item === 'string' + const len = (arr || str) && item.length + if (arr) return len === 33 || len === 65 + if (str) return len === 66 || len === 130 + if (item instanceof Point) return true + return false + } + function getSharedSecret(privateA, publicB, isCompressed = false) { + if (isPub(privateA)) + throw new TypeError('getSharedSecret: first arg must be private key') + if (!isPub(publicB)) + throw new TypeError('getSharedSecret: second arg must be public key') + const b = normalizePublicKey(publicB) + b.assertValidity() + return b.multiply(normalizePrivateKey(privateA)).toRawBytes(isCompressed) + } + function bits2int(bytes) { + const slice = bytes.length > 32 ? bytes.slice(0, 32) : bytes + return bytesToNumber(slice) + } + function bits2octets(bytes) { + const z1 = bits2int(bytes) + const z2 = mod(z1, CURVE.n) + return int2octets(z2 < _0n ? z1 : z2) + } + function int2octets(num) { + if (typeof num !== 'bigint') throw new Error('Expected bigint') + const hex = numTo32bStr(num) + return hexToBytes(hex) + } + function initSigArgs(msgHash, privateKey, extraEntropy) { + if (msgHash == null) + throw new Error(`sign: expected valid message hash, not "${msgHash}"`) + const h1 = ensureBytes(msgHash) + const d = normalizePrivateKey(privateKey) + const seedArgs = [int2octets(d), bits2octets(h1)] + if (extraEntropy != null) { + if (extraEntropy === true) extraEntropy = utils.randomBytes(32) + const e = ensureBytes(extraEntropy) + if (e.length !== 32) + throw new Error('sign: Expected 32 bytes of extra data') + seedArgs.push(e) + } + const seed = concatBytes(...seedArgs) + const m = bits2int(h1) + return {seed, m, d} + } + function finalizeSig(recSig, opts) { + let {sig, recovery} = recSig + const {canonical, der, recovered} = Object.assign( + {canonical: true, der: true}, + opts + ) + if (canonical && sig.hasHighS()) { + sig = sig.normalizeS() + recovery ^= 1 + } + const hashed = der ? sig.toDERRawBytes() : sig.toCompactRawBytes() + return recovered ? [hashed, recovery] : hashed + } + async function sign(msgHash, privKey, opts = {}) { + const {seed, m, d} = initSigArgs(msgHash, privKey, opts.extraEntropy) + let sig + const drbg = new HmacDrbg() + await drbg.reseed(seed) + while (!(sig = kmdToSig(await drbg.generate(), m, d))) await drbg.reseed() + return finalizeSig(sig, opts) + } + function signSync(msgHash, privKey, opts = {}) { + const {seed, m, d} = initSigArgs(msgHash, privKey, opts.extraEntropy) + let sig + const drbg = new HmacDrbg() + drbg.reseedSync(seed) + while (!(sig = kmdToSig(drbg.generateSync(), m, d))) drbg.reseedSync() + return finalizeSig(sig, opts) + } + const vopts = {strict: true} + function verify(signature, msgHash, publicKey, opts = vopts) { + let sig + try { + sig = normalizeSignature(signature) + msgHash = ensureBytes(msgHash) + } catch (error) { + return false + } + const {r, s} = sig + if (opts.strict && sig.hasHighS()) return false + const h = truncateHash(msgHash) + let P + try { + P = normalizePublicKey(publicKey) + } catch (error) { + return false + } + const {n} = CURVE + const sinv = invert(s, n) + const u1 = mod(h * sinv, n) + const u2 = mod(r * sinv, n) + const R = Point.BASE.multiplyAndAddUnsafe(P, u1, u2) + if (!R) return false + const v = mod(R.x, n) + return v === r + } + function finalizeSchnorrChallenge(ch) { + return mod(bytesToNumber(ch), CURVE.n) + } + function hasEvenY(point) { + return (point.y & _1n) === _0n + } + class SchnorrSignature { + constructor(r, s) { + this.r = r + this.s = s + this.assertValidity() + } + static fromHex(hex) { + const bytes = ensureBytes(hex) + if (bytes.length !== 64) + throw new TypeError( + `SchnorrSignature.fromHex: expected 64 bytes, not ${bytes.length}` + ) + const r = bytesToNumber(bytes.subarray(0, 32)) + const s = bytesToNumber(bytes.subarray(32, 64)) + return new SchnorrSignature(r, s) + } + assertValidity() { + const {r, s} = this + if (!isValidFieldElement(r) || !isWithinCurveOrder(s)) + throw new Error('Invalid signature') + } + toHex() { + return numTo32bStr(this.r) + numTo32bStr(this.s) + } + toRawBytes() { + return hexToBytes(this.toHex()) + } + } + function schnorrGetPublicKey(privateKey) { + return Point.fromPrivateKey(privateKey).toRawX() + } + function initSchnorrSigArgs(message, privateKey, auxRand) { + if (message == null) + throw new TypeError(`sign: Expected valid message, not "${message}"`) + const m = ensureBytes(message) + const d0 = normalizePrivateKey(privateKey) + const rand = ensureBytes(auxRand) + if (rand.length !== 32) + throw new TypeError('sign: Expected 32 bytes of aux randomness') + const P = Point.fromPrivateKey(d0) + const px = P.toRawX() + const d = hasEvenY(P) ? d0 : CURVE.n - d0 + return {m, P, px, d, rand} + } + function initSchnorrNonce(d, t0h) { + return numTo32b(d ^ bytesToNumber(t0h)) + } + function finalizeSchnorrNonce(k0h) { + const k0 = mod(bytesToNumber(k0h), CURVE.n) + if (k0 === _0n) + throw new Error('sign: Creation of signature failed. k is zero') + const R = Point.fromPrivateKey(k0) + const rx = R.toRawX() + const k = hasEvenY(R) ? k0 : CURVE.n - k0 + return {R, rx, k} + } + function finalizeSchnorrSig(R, k, e, d) { + return new SchnorrSignature(R.x, mod(k + e * d, CURVE.n)).toRawBytes() + } + async function schnorrSign( + message, + privateKey, + auxRand = utils.randomBytes() + ) { + const {m, px, d, rand} = initSchnorrSigArgs(message, privateKey, auxRand) + const t = initSchnorrNonce(d, await utils.taggedHash(TAGS.aux, rand)) + const {R, rx, k} = finalizeSchnorrNonce( + await utils.taggedHash(TAGS.nonce, t, px, m) + ) + const e = finalizeSchnorrChallenge( + await utils.taggedHash(TAGS.challenge, rx, px, m) + ) + const sig = finalizeSchnorrSig(R, k, e, d) + const isValid = await schnorrVerify(sig, m, px) + if (!isValid) throw new Error('sign: Invalid signature produced') + return sig + } + function schnorrSignSync(message, privateKey, auxRand = utils.randomBytes()) { + const {m, px, d, rand} = initSchnorrSigArgs(message, privateKey, auxRand) + const t = initSchnorrNonce(d, utils.taggedHashSync(TAGS.aux, rand)) + const {R, rx, k} = finalizeSchnorrNonce( + utils.taggedHashSync(TAGS.nonce, t, px, m) + ) + const e = finalizeSchnorrChallenge( + utils.taggedHashSync(TAGS.challenge, rx, px, m) + ) + const sig = finalizeSchnorrSig(R, k, e, d) + const isValid = schnorrVerifySync(sig, m, px) + if (!isValid) throw new Error('sign: Invalid signature produced') + return sig + } + function initSchnorrVerify(signature, message, publicKey) { + const raw = signature instanceof SchnorrSignature + const sig = raw ? signature : SchnorrSignature.fromHex(signature) + if (raw) sig.assertValidity() + return { + ...sig, + m: ensureBytes(message), + P: normalizePublicKey(publicKey) + } + } + function finalizeSchnorrVerify(r, P, s, e) { + const R = Point.BASE.multiplyAndAddUnsafe( + P, + normalizePrivateKey(s), + mod(-e, CURVE.n) + ) + if (!R || !hasEvenY(R) || R.x !== r) return false + return true + } + async function schnorrVerify(signature, message, publicKey) { + try { + const {r, s, m, P} = initSchnorrVerify(signature, message, publicKey) + const e = finalizeSchnorrChallenge( + await utils.taggedHash(TAGS.challenge, numTo32b(r), P.toRawX(), m) + ) + return finalizeSchnorrVerify(r, P, s, e) + } catch (error) { + return false + } + } + function schnorrVerifySync(signature, message, publicKey) { + try { + const {r, s, m, P} = initSchnorrVerify(signature, message, publicKey) + const e = finalizeSchnorrChallenge( + utils.taggedHashSync(TAGS.challenge, numTo32b(r), P.toRawX(), m) + ) + return finalizeSchnorrVerify(r, P, s, e) + } catch (error) { + return false + } + } + const schnorr = { + Signature: SchnorrSignature, + getPublicKey: schnorrGetPublicKey, + sign: schnorrSign, + verify: schnorrVerify, + signSync: schnorrSignSync, + verifySync: schnorrVerifySync + } + Point.BASE._setWindowSize(8) + const crypto = { + node: nodeCrypto, + web: typeof self === 'object' && 'crypto' in self ? self.crypto : undefined + } + const TAGS = { + challenge: 'BIP0340/challenge', + aux: 'BIP0340/aux', + nonce: 'BIP0340/nonce' + } + const TAGGED_HASH_PREFIXES = {} + const utils = { + isValidPrivateKey(privateKey) { + try { + normalizePrivateKey(privateKey) + return true + } catch (error) { + return false + } + }, + privateAdd: (privateKey, tweak) => { + const p = normalizePrivateKey(privateKey) + const t = normalizePrivateKey(tweak) + return numTo32b(mod(p + t, CURVE.n)) + }, + privateNegate: privateKey => { + const p = normalizePrivateKey(privateKey) + return numTo32b(CURVE.n - p) + }, + pointAddScalar: (p, tweak, isCompressed) => { + const P = Point.fromHex(p) + const t = normalizePrivateKey(tweak) + const Q = Point.BASE.multiplyAndAddUnsafe(P, t, _1n) + if (!Q) throw new Error('Tweaked point at infinity') + return Q.toRawBytes(isCompressed) + }, + pointMultiply: (p, tweak, isCompressed) => { + const P = Point.fromHex(p) + const t = bytesToNumber(ensureBytes(tweak)) + return P.multiply(t).toRawBytes(isCompressed) + }, + hashToPrivateKey: hash => { + hash = ensureBytes(hash) + if (hash.length < 40 || hash.length > 1024) + throw new Error('Expected 40-1024 bytes of private key as per FIPS 186') + const num = mod(bytesToNumber(hash), CURVE.n - _1n) + _1n + return numTo32b(num) + }, + randomBytes: (bytesLength = 32) => { + if (crypto.web) { + return crypto.web.getRandomValues(new Uint8Array(bytesLength)) + } else if (crypto.node) { + const {randomBytes} = crypto.node + return Uint8Array.from(randomBytes(bytesLength)) + } else { + throw new Error("The environment doesn't have randomBytes function") + } + }, + randomPrivateKey: () => { + return utils.hashToPrivateKey(utils.randomBytes(40)) + }, + bytesToHex, + hexToBytes, + concatBytes, + mod, + invert, + sha256: async (...messages) => { + if (crypto.web) { + const buffer = await crypto.web.subtle.digest( + 'SHA-256', + concatBytes(...messages) + ) + return new Uint8Array(buffer) + } else if (crypto.node) { + const {createHash} = crypto.node + const hash = createHash('sha256') + messages.forEach(m => hash.update(m)) + return Uint8Array.from(hash.digest()) + } else { + throw new Error("The environment doesn't have sha256 function") + } + }, + hmacSha256: async (key, ...messages) => { + if (crypto.web) { + const ckey = await crypto.web.subtle.importKey( + 'raw', + key, + {name: 'HMAC', hash: {name: 'SHA-256'}}, + false, + ['sign'] + ) + const message = concatBytes(...messages) + const buffer = await crypto.web.subtle.sign('HMAC', ckey, message) + return new Uint8Array(buffer) + } else if (crypto.node) { + const {createHmac} = crypto.node + const hash = createHmac('sha256', key) + messages.forEach(m => hash.update(m)) + return Uint8Array.from(hash.digest()) + } else { + throw new Error("The environment doesn't have hmac-sha256 function") + } + }, + sha256Sync: undefined, + hmacSha256Sync: undefined, + taggedHash: async (tag, ...messages) => { + let tagP = TAGGED_HASH_PREFIXES[tag] + if (tagP === undefined) { + const tagH = await utils.sha256( + Uint8Array.from(tag, c => c.charCodeAt(0)) + ) + tagP = concatBytes(tagH, tagH) + TAGGED_HASH_PREFIXES[tag] = tagP + } + return utils.sha256(tagP, ...messages) + }, + taggedHashSync: (tag, ...messages) => { + if (typeof utils.sha256Sync !== 'function') + throw new Error('utils.sha256Sync is undefined, you need to set it') + let tagP = TAGGED_HASH_PREFIXES[tag] + if (tagP === undefined) { + const tagH = utils.sha256Sync( + Uint8Array.from(tag, c => c.charCodeAt(0)) + ) + tagP = concatBytes(tagH, tagH) + TAGGED_HASH_PREFIXES[tag] = tagP + } + return utils.sha256Sync(tagP, ...messages) + }, + precompute(windowSize = 8, point = Point.BASE) { + const cached = point === Point.BASE ? point : new Point(point.x, point.y) + cached._setWindowSize(windowSize) + cached.multiply(_3n) + return cached + } + } + + exports.CURVE = CURVE + exports.Point = Point + exports.Signature = Signature + exports.getPublicKey = getPublicKey + exports.getSharedSecret = getSharedSecret + exports.recoverPublicKey = recoverPublicKey + exports.schnorr = schnorr + exports.sign = sign + exports.signSync = signSync + exports.utils = utils + exports.verify = verify + + Object.defineProperty(exports, '__esModule', {value: true}) +}) diff --git a/lnbits/extensions/cashu/static/js/utils.js b/lnbits/extensions/cashu/static/js/utils.js new file mode 100644 index 000000000..cf852b58b --- /dev/null +++ b/lnbits/extensions/cashu/static/js/utils.js @@ -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}`) +} diff --git a/lnbits/extensions/cashu/tasks.py b/lnbits/extensions/cashu/tasks.py new file mode 100644 index 000000000..9de17a1cf --- /dev/null +++ b/lnbits/extensions/cashu/tasks.py @@ -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 diff --git a/lnbits/extensions/cashu/templates/cashu/_api_docs.html b/lnbits/extensions/cashu/templates/cashu/_api_docs.html new file mode 100644 index 000000000..f7bb19f60 --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/_api_docs.html @@ -0,0 +1,80 @@ + + + + diff --git a/lnbits/extensions/cashu/templates/cashu/_cashu.html b/lnbits/extensions/cashu/templates/cashu/_cashu.html new file mode 100644 index 000000000..952fe7e1a --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/_cashu.html @@ -0,0 +1,13 @@ + + + +

Create Cashu ecash mints and wallets.

+ Created by + arcbtc, + vlad, + calle. +
+
+
diff --git a/lnbits/extensions/cashu/templates/cashu/index.html b/lnbits/extensions/cashu/templates/cashu/index.html new file mode 100644 index 000000000..2599669cf --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/index.html @@ -0,0 +1,367 @@ +{% extends "base.html" %} {% from "macros.jinja" import window_vars with context +%} {% block page %} +
+
+ + + Cashu mint and wallet +

+

+ 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. +

+ Important +

+

+ 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. +

+
+
+ + + +
+
+
Mints
+
+
+ Export to CSV +
+
+ + {% raw %} + + + + {% endraw %} + + New Mint +
+
+
+ +
+ + +
{{SITE_TITLE}} Cashu extension
+
+ + + + {% include "cashu/_api_docs.html" %} + + {% include "cashu/_cashu.html" %} + + +
+
+ + + + + + + +
+ Create Mint + + Cancel +
+
+
+
+
+{% endblock %} {% block scripts %} {{ window_vars(user) }} + +{% endblock %} diff --git a/lnbits/extensions/cashu/templates/cashu/mint.html b/lnbits/extensions/cashu/templates/cashu/mint.html new file mode 100644 index 000000000..ee6ab606c --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/mint.html @@ -0,0 +1,76 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+ +

{{ mint_name }}

+ Open wallet +
+
+
+ + +
Read the following carefully!
+

+ This is a + Cashu + mint. Cashu is an ecash system for Bitcoin. +

+

+ Open this page in your native browser
+ 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. +

+

+ Add wallet to home screen
+ 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. +

+

+ Backup your wallet
+ 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. +

+

+ This service is in BETA
+ We hold no responsibility for people losing access to funds. Use at + your own risk! +

+
+
+
+ + {% endblock %} {% block scripts %} + + + + {% endblock %} +
diff --git a/lnbits/extensions/cashu/templates/cashu/wallet.html b/lnbits/extensions/cashu/templates/cashu/wallet.html new file mode 100644 index 000000000..a133f5920 --- /dev/null +++ b/lnbits/extensions/cashu/templates/cashu/wallet.html @@ -0,0 +1,2337 @@ +{% extends "public.html" %} {% block toolbar_title %} {% raw %} {{name}} Cashu +{% endraw %} {% endblock %} {% block footer %}{% endblock %} {% block +page_container %} + + +
+
+ + +
+
+
+ Get invoice + +
+
+

+
+ {% raw %} {{getBalance()}} + {{tickershort}}{% endraw %} +
+

+
+
+ Pay invoice + +
+
+
+
+
+
+

+
+ {% raw %} {{getBalance()}} + {{tickershort}}{% endraw %} +
+

+
+
+
+
+
+ + + +
+
+ Get Ecash +
+
+
+ + Pay Ecash +
+
+ + + + + + + + + + + + + {% raw %} + + {% endraw %} + + + + + + + + {% raw %} + + {% endraw %} + + + + + + + + {% raw %} + + {% endraw %} + + + +
+
+ +
+ + Warning + BackupDownload wallet backup +
+
+ + + + + + + + + + + + +
+
+ {% raw %} {{ + parseFloat(String(payInvoiceData.invoice.fsat).replaceAll(",", + "")) / 100 }} {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} +
+
+ {{ payInvoiceData.invoice.fsat }}{% endraw %} + {{LNBITS_DENOMINATION}} {% raw %} +
+ +

+ Description: {{ + payInvoiceData.invoice.description }}
+ Expire date: {{ payInvoiceData.invoice.expireDate + }}
+ Hash: {{ payInvoiceData.invoice.hash }} +

+ {% endraw %} +
+ Pay + Cancel +
+
+ Not enough funds! + Cancel +
+
+
+ {% raw %} + +

+ Authenticate with {{ payInvoiceData.lnurlauth.domain }}? +

+ +

+ For every website and for every LNbits wallet, a new keypair + will be deterministically generated so your identity can't be + tied to your LNbits wallet or linked across websites. No other + data will be shared with {{ payInvoiceData.lnurlauth.domain }}. +

+

+ Your public key for + {{ payInvoiceData.lnurlauth.domain }} is: +

+

+ + {{ payInvoiceData.lnurlauth.pubkey }} + +

+
+ Login + Cancel +
+
+ {% endraw %} +
+
+ {% raw %} + +

+ {{ payInvoiceData.lnurlpay.domain }} is requesting {{ + payInvoiceData.lnurlpay.maxSendable | msatoshiFormat }} + {{LNBITS_DENOMINATION}} + +
+ and a {{payInvoiceData.lnurlpay.commentAllowed}}-char comment +
+

+

+ {{ payInvoiceData.lnurlpay.targetUser || + payInvoiceData.lnurlpay.domain }} + is requesting
+ between + {{ payInvoiceData.lnurlpay.minSendable | msatoshiFormat }} + and + {{ payInvoiceData.lnurlpay.maxSendable | msatoshiFormat }} + {% endraw %} {{LNBITS_DENOMINATION}} {% raw %} + +
+ and a {{payInvoiceData.lnurlpay.commentAllowed}}-char comment +
+

+ +
+

+ {{ payInvoiceData.lnurlpay.description }} +

+

+ +

+
+
+
+ {% endraw %} + + {% raw %} +
+
+ +
+
+
+ Send {{LNBITS_DENOMINATION}} + Cancel +
+
+ {% endraw %} +
+
+ + + +
+ Enter + + + Close +
+
+
+ + + +
+ + Cancel + +
+
+
+
+
+ + + +
+ +
+
+ Cancel +
+
+
+ + + +
Warning
+

+ Bookmark this page and backup your tokens! + Ecash is a bearer asset, meaning losing access to this wallet will + mean you will lose the funds. This wallet stores ecash tokens in its + database. If you lose the link or delete your your data without + backing up, you will lose your tokens. Press the Backup button to + download a copy of your tokens. +

+

+ Add to home screen. + You can add Cashu to your home screen as a progressive web app + (PWA). On Android Chrome, click the hamburger menu at the upper + right. On iOS Safari, click the share button. Now press the Add to + Home screen button. +

+

+ This service is in BETA! We hold no responsibility + for people losing access to funds. Use at your own risk! +

+
+ Copy wallet URL + I understand +
+
+
+ + + +
+
+
+ Create a Lightning invoice +
+
+ + +
+ +
+ Copy invoice + Create Invoice + Close +
+
+
+ + + +
+
+
+ How much would you like to send? +
+
+ + +
+
+
+ + + + + + +
+ +
+
+ Send Tokens + +
+ Copy token + Copy link +
+ + Close +
+
+
+ + + +
+
+
+ Receive Cashu tokens +
+
+ +
+ +
+ Receive Tokens + + Close +
+
+
+
+
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + + + + + +{% endblock %} diff --git a/lnbits/extensions/cashu/views.py b/lnbits/extensions/cashu/views.py new file mode 100644 index 000000000..0de791c40 --- /dev/null +++ b/lnbits/extensions/cashu/views.py @@ -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", + }, + ], + } + ], + } diff --git a/lnbits/extensions/cashu/views_api.py b/lnbits/extensions/cashu/views_api.py new file mode 100644 index 000000000..ad253abf0 --- /dev/null +++ b/lnbits/extensions/cashu/views_api.py @@ -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 diff --git a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html index b0b223fff..25dcf8c99 100644 --- a/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html +++ b/lnbits/extensions/lnurldevice/templates/lnurldevice/index.html @@ -487,6 +487,17 @@ @click="copyText(lnurlValue, 'LNURL copied to clipboard!')" >Copy LNURL + {% raw %}{{ wsMessage }}{% endraw %} + {% raw %}{{ wsMessage }}{% endraw %}
Charges: + data = CreateCharge(**data.dict()) charge_id = urlsafe_short_hash() if data.onchainwallet: + config = await get_config(user) + data.extra = json.dumps( + {"mempool_endpoint": config.mempool_endpoint, "network": config.network} + ) onchain = await get_fresh_address(data.onchainwallet) onchainaddress = onchain.address else: @@ -48,9 +53,11 @@ async def create_charge(user: str, data: CreateCharge) -> Charges: completelinktext, time, amount, - balance + balance, + extra, + custom_css ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( charge_id, @@ -67,6 +74,8 @@ async def create_charge(user: str, data: CreateCharge) -> Charges: data.time, data.amount, 0, + data.extra, + data.custom_css, ), ) return await get_charge(charge_id) @@ -98,34 +107,118 @@ async def delete_charge(charge_id: str) -> None: await db.execute("DELETE FROM satspay.charges WHERE id = ?", (charge_id,)) -async def check_address_balance(charge_id: str) -> List[Charges]: +async def check_address_balance(charge_id: str) -> Optional[Charges]: charge = await get_charge(charge_id) + if not charge.paid: if charge.onchainaddress: - config = await get_charge_config(charge_id) try: - async with httpx.AsyncClient() as client: - r = await client.get( - config.mempool_endpoint - + "/api/address/" - + charge.onchainaddress - ) - respAmount = r.json()["chain_stats"]["funded_txo_sum"] - if respAmount > charge.balance: - await update_charge(charge_id=charge_id, balance=respAmount) - except Exception: - pass + respAmount = await fetch_onchain_balance(charge) + if respAmount > charge.balance: + await update_charge(charge_id=charge_id, balance=respAmount) + except Exception as e: + logger.warning(e) if charge.lnbitswallet: invoice_status = await api_payment(charge.payment_hash) if invoice_status["paid"]: return await update_charge(charge_id=charge_id, balance=charge.amount) - row = await db.fetchone("SELECT * FROM satspay.charges WHERE id = ?", (charge_id,)) - return Charges.from_row(row) if row else None + return await get_charge(charge_id) -async def get_charge_config(charge_id: str): - row = await db.fetchone( - """SELECT "user" FROM satspay.charges WHERE id = ?""", (charge_id,) +################## SETTINGS ################### + + +async def save_theme(data: SatsPayThemes, css_id: str = None): + # insert or update + if css_id: + await db.execute( + """ + UPDATE satspay.themes SET custom_css = ?, title = ? WHERE css_id = ? + """, + (data.custom_css, data.title, css_id), + ) + else: + css_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO satspay.themes ( + css_id, + title, + user, + custom_css + ) + VALUES (?, ?, ?, ?) + """, + ( + css_id, + data.title, + data.user, + data.custom_css, + ), + ) + return await get_theme(css_id) + + +async def get_theme(css_id: str) -> SatsPayThemes: + row = await db.fetchone("SELECT * FROM satspay.themes WHERE css_id = ?", (css_id,)) + return SatsPayThemes.from_row(row) if row else None + + +async def get_themes(user_id: str) -> List[SatsPayThemes]: + rows = await db.fetchall( + """SELECT * FROM satspay.themes WHERE "user" = ? ORDER BY "timestamp" DESC """, + (user_id,), ) return await get_config(row.user) + + +################## SETTINGS ################### + + +async def save_theme(data: SatsPayThemes, css_id: str = None): + # insert or update + if css_id: + await db.execute( + """ + UPDATE satspay.themes SET custom_css = ?, title = ? WHERE css_id = ? + """, + (data.custom_css, data.title, css_id), + ) + else: + css_id = urlsafe_short_hash() + await db.execute( + """ + INSERT INTO satspay.themes ( + css_id, + title, + "user", + custom_css + ) + VALUES (?, ?, ?, ?) + """, + ( + css_id, + data.title, + data.user, + data.custom_css, + ), + ) + return await get_theme(css_id) + + +async def get_theme(css_id: str) -> SatsPayThemes: + row = await db.fetchone("SELECT * FROM satspay.themes WHERE css_id = ?", (css_id,)) + return SatsPayThemes.from_row(row) if row else None + + +async def get_themes(user_id: str) -> List[SatsPayThemes]: + rows = await db.fetchall( + """SELECT * FROM satspay.themes WHERE "user" = ? ORDER BY "title" DESC """, + (user_id,), + ) + return [SatsPayThemes.from_row(row) for row in rows] + + +async def delete_theme(theme_id: str) -> None: + await db.execute("DELETE FROM satspay.themes WHERE css_id = ?", (theme_id,)) diff --git a/lnbits/extensions/satspay/helpers.py b/lnbits/extensions/satspay/helpers.py index 2d15b5578..60c5ba4ab 100644 --- a/lnbits/extensions/satspay/helpers.py +++ b/lnbits/extensions/satspay/helpers.py @@ -1,8 +1,11 @@ +import httpx +from loguru import logger + from .models import Charges -def compact_charge(charge: Charges): - return { +def public_charge(charge: Charges): + c = { "id": charge.id, "description": charge.description, "onchainaddress": charge.onchainaddress, @@ -13,5 +16,40 @@ def compact_charge(charge: Charges): "balance": charge.balance, "paid": charge.paid, "timestamp": charge.timestamp, - "completelink": charge.completelink, # should be secret? + "time_elapsed": charge.time_elapsed, + "time_left": charge.time_left, + "paid": charge.paid, + "custom_css": charge.custom_css, } + + if charge.paid: + c["completelink"] = charge.completelink + c["completelinktext"] = charge.completelinktext + + return c + + +async def call_webhook(charge: Charges): + async with httpx.AsyncClient() as client: + try: + r = await client.post( + charge.webhook, + json=public_charge(charge), + timeout=40, + ) + return {"webhook_success": r.is_success, "webhook_message": r.reason_phrase} + except Exception as e: + logger.warning(f"Failed to call webhook for charge {charge.id}") + logger.warning(e) + return {"webhook_success": False, "webhook_message": str(e)} + + +async def fetch_onchain_balance(charge: Charges): + endpoint = ( + f"{charge.config.mempool_endpoint}/testnet" + if charge.config.network == "Testnet" + else charge.config.mempool_endpoint + ) + async with httpx.AsyncClient() as client: + r = await client.get(endpoint + "/api/address/" + charge.onchainaddress) + return r.json()["chain_stats"]["funded_txo_sum"] diff --git a/lnbits/extensions/satspay/migrations.py b/lnbits/extensions/satspay/migrations.py index 87446c800..e23bd413b 100644 --- a/lnbits/extensions/satspay/migrations.py +++ b/lnbits/extensions/satspay/migrations.py @@ -4,7 +4,7 @@ async def m001_initial(db): """ await db.execute( - """ + f""" CREATE TABLE satspay.charges ( id TEXT NOT NULL PRIMARY KEY, "user" TEXT, @@ -18,11 +18,47 @@ async def m001_initial(db): completelink TEXT, completelinktext TEXT, time INTEGER, - amount INTEGER, - balance INTEGER DEFAULT 0, + amount {db.big_int}, + balance {db.big_int} DEFAULT 0, timestamp TIMESTAMP NOT NULL DEFAULT """ + db.timestamp_now + """ ); """ ) + + +async def m002_add_charge_extra_data(db): + """ + Add 'extra' column for storing various config about the charge (JSON format) + """ + await db.execute( + """ALTER TABLE satspay.charges + ADD COLUMN extra TEXT DEFAULT '{"mempool_endpoint": "https://mempool.space", "network": "Mainnet"}'; + """ + ) + + +async def m003_add_themes_table(db): + """ + Themes table + """ + + await db.execute( + """ + CREATE TABLE satspay.themes ( + css_id TEXT NOT NULL PRIMARY KEY, + "user" TEXT, + title TEXT, + custom_css TEXT + ); + """ + ) + + +async def m004_add_custom_css_to_charges(db): + """ + Add custom css option column to the 'charges' table + """ + + await db.execute("ALTER TABLE satspay.charges ADD COLUMN custom_css TEXT;") diff --git a/lnbits/extensions/satspay/models.py b/lnbits/extensions/satspay/models.py index daf63f429..4d30f9158 100644 --- a/lnbits/extensions/satspay/models.py +++ b/lnbits/extensions/satspay/models.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta from sqlite3 import Row from typing import Optional @@ -5,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) @@ -13,8 +18,17 @@ class CreateCharge(BaseModel): webhook: str = Query(None) completelink: str = Query(None) completelinktext: str = Query(None) + custom_css: Optional[str] time: int = Query(..., ge=1) amount: int = Query(..., ge=1) + extra: str = DEFAULT_MEMPOOL_CONFIG + + +class ChargeConfig(BaseModel): + mempool_endpoint: Optional[str] + network: Optional[str] + webhook_success: Optional[bool] = False + webhook_message: Optional[str] class Charges(BaseModel): @@ -28,6 +42,13 @@ class Charges(BaseModel): webhook: Optional[str] completelink: Optional[str] completelinktext: Optional[str] = "Back to Merchant" +<<<<<<< HEAD + extra: str = "{}" + custom_css: Optional[str] +======= + custom_css: Optional[str] + extra: str = DEFAULT_MEMPOOL_CONFIG +>>>>>>> main time: int amount: int balance: int @@ -54,3 +75,22 @@ class Charges(BaseModel): return True else: return False + + @property + def config(self) -> ChargeConfig: + charge_config = json.loads(self.extra) + return ChargeConfig(**charge_config) + + def must_call_webhook(self): + return self.webhook and self.paid and self.config.webhook_success == False + + +class SatsPayThemes(BaseModel): + css_id: str = Query(None) + title: str = Query(None) + custom_css: str = Query(None) + user: Optional[str] + + @classmethod + def from_row(cls, row: Row) -> "SatsPayThemes": + return cls(**dict(row)) diff --git a/lnbits/extensions/satspay/static/js/utils.js b/lnbits/extensions/satspay/static/js/utils.js index 9b4abbfca..2b1be8bdc 100644 --- a/lnbits/extensions/satspay/static/js/utils.js +++ b/lnbits/extensions/satspay/static/js/utils.js @@ -14,18 +14,22 @@ const retryWithDelay = async function (fn, retryCount = 0) { } const mapCharge = (obj, oldObj = {}) => { - const charge = _.clone(obj) + const charge = {...oldObj, ...obj} charge.progress = obj.time_left < 0 ? 1 : 1 - obj.time_left / obj.time charge.time = minutesToTime(obj.time) charge.timeLeft = minutesToTime(obj.time_left) - charge.expanded = false charge.displayUrl = ['/satspay/', obj.id].join('') - charge.expanded = oldObj.expanded + charge.expanded = oldObj.expanded || false charge.pendingBalance = oldObj.pendingBalance || 0 return charge } +const mapCSS = (obj, oldObj = {}) => { + const theme = _.clone(obj) + return theme +} + const minutesToTime = min => min > 0 ? new Date(min * 1000).toISOString().substring(14, 19) : '' diff --git a/lnbits/extensions/satspay/tasks.py b/lnbits/extensions/satspay/tasks.py index 46c16bbc9..ce54b44a2 100644 --- a/lnbits/extensions/satspay/tasks.py +++ b/lnbits/extensions/satspay/tasks.py @@ -1,4 +1,5 @@ import asyncio +import json from loguru import logger @@ -7,7 +8,8 @@ from lnbits.extensions.satspay.crud import check_address_balance, get_charge from lnbits.helpers import get_current_extension_name from lnbits.tasks import register_invoice_listener -# from .crud import get_ticket, set_ticket_paid +from .crud import update_charge +from .helpers import call_webhook async def wait_for_paid_invoices(): @@ -30,4 +32,9 @@ async def on_invoice_paid(payment: Payment) -> None: return await payment.set_pending(False) - await check_address_balance(charge_id=charge.id) + charge = await check_address_balance(charge_id=charge.id) + + if charge.must_call_webhook(): + resp = await call_webhook(charge) + extra = {**charge.config.dict(), **resp} + await update_charge(charge_id=charge.id, extra=json.dumps(extra)) diff --git a/lnbits/extensions/satspay/templates/satspay/_api_docs.html b/lnbits/extensions/satspay/templates/satspay/_api_docs.html index ed6587357..6d5ae661e 100644 --- a/lnbits/extensions/satspay/templates/satspay/_api_docs.html +++ b/lnbits/extensions/satspay/templates/satspay/_api_docs.html @@ -5,7 +5,13 @@ WatchOnly extension, we highly reccomend using a fresh extended public Key specifically for SatsPayServer!
- Created by, Ben ArcBen Arc, + motorina0


diff --git a/lnbits/extensions/satspay/templates/satspay/display.html b/lnbits/extensions/satspay/templates/satspay/display.html index 12288c809..8ea218bdd 100644 --- a/lnbits/extensions/satspay/templates/satspay/display.html +++ b/lnbits/extensions/satspay/templates/satspay/display.html @@ -109,7 +109,7 @@ @@ -131,7 +131,7 @@ @@ -170,13 +170,17 @@ name="check" style="color: green; font-size: 21.4em" > - +
+
+ +
+
@@ -218,7 +222,7 @@
@@ -289,7 +297,17 @@
- +{% endblock %} {% block styles %} + + {% endblock %} {% block scripts %} @@ -303,7 +321,8 @@ data() { return { charge: JSON.parse('{{charge_data | tojson}}'), - mempool_endpoint: '{{mempool_endpoint}}', + mempoolEndpoint: '{{mempool_endpoint}}', + network: '{{network}}', pendingFunds: 0, ws: null, newProgress: 0.4, @@ -316,19 +335,19 @@ cancelListener: () => {} } }, + computed: { + mempoolHostname: function () { + let hostname = new URL(this.mempoolEndpoint).hostname + if (this.network === 'Testnet') { + hostname += '/testnet' + } + return hostname + } + }, methods: { - startPaymentNotifier() { - this.cancelListener() - if (!this.lnbitswallet) return - this.cancelListener = LNbits.events.onInvoicePaid( - this.wallet, - payment => { - this.checkInvoiceBalance() - } - ) - }, checkBalances: async function () { - if (this.charge.hasStaleBalance) return + if (!this.charge.payment_request && this.charge.hasOnchainStaleBalance) + return try { const {data} = await LNbits.api.request( 'GET', @@ -345,7 +364,7 @@ const { bitcoin: {addresses: addressesAPI} } = mempoolJS({ - hostname: new URL(this.mempool_endpoint).hostname + hostname: new URL(this.mempoolEndpoint).hostname }) try { @@ -353,7 +372,8 @@ address: this.charge.onchainaddress }) const newBalance = utxos.reduce((t, u) => t + u.value, 0) - this.charge.hasStaleBalance = this.charge.balance === newBalance + this.charge.hasOnchainStaleBalance = + this.charge.balance === newBalance this.pendingFunds = utxos .filter(u => !u.status.confirmed) @@ -388,10 +408,10 @@ const { bitcoin: {websocket} } = mempoolJS({ - hostname: new URL(this.mempool_endpoint).hostname + hostname: new URL(this.mempoolEndpoint).hostname }) - this.ws = new WebSocket('wss://mempool.space/api/v1/ws') + this.ws = new WebSocket(`wss://${this.mempoolHostname}/api/v1/ws`) this.ws.addEventListener('open', x => { if (this.charge.onchainaddress) { this.trackAddress(this.charge.onchainaddress) @@ -428,13 +448,14 @@ } }, created: async function () { - if (this.charge.lnbitswallet) this.payInvoice() + // Remove a user defined theme + if (this.charge.custom_css) { + document.body.setAttribute('data-theme', '') + } + if (this.charge.payment_request) this.payInvoice() else this.payOnchain() - await this.checkBalances() - // empty for onchain - this.wallet.inkey = '{{ wallet_inkey }}' - this.startPaymentNotifier() + await this.checkBalances() if (!this.charge.paid) { this.loopRefresh() diff --git a/lnbits/extensions/satspay/templates/satspay/index.html b/lnbits/extensions/satspay/templates/satspay/index.html index 396200cf1..602b1a288 100644 --- a/lnbits/extensions/satspay/templates/satspay/index.html +++ b/lnbits/extensions/satspay/templates/satspay/index.html @@ -8,6 +8,26 @@ New charge + + New CSS Theme + + New CSS Theme + For security reason, custom css is only available to server + admins. @@ -203,9 +223,14 @@ :href="props.row.webhook" target="_blank" style="color: unset; text-decoration: none" - >{{props.row.webhook || props.row.webhook}}
{{props.row.webhook}}
+
+ + {{props.row.webhook_message }} + +
ID:
@@ -254,6 +279,63 @@ + + + +
+
+
Themes
+
+
+ + {% raw %} + + + + {% endraw %} + +
+
@@ -298,32 +380,6 @@ > - - - - - - - -
@@ -372,6 +428,52 @@ label="Wallet *" > + +
+
+ + + + + + + + + +
+
+ + + + + + + +
+ Update CSS theme + Save CSS theme + Cancel +
+
+
+
{% endblock %} {% block scripts %} {{ window_vars(user) }}