commit d33735faa07d3c292e928b49a004a59cf63255bd Author: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon Feb 13 10:53:29 2023 +0100 init diff --git a/Cashu_Chrome_extension.crx b/Cashu_Chrome_extension.crx new file mode 100644 index 000000000..1c3595cf6 Binary files /dev/null and b/Cashu_Chrome_extension.crx differ diff --git a/README.md b/README.md new file mode 100644 index 000000000..8f53b474b --- /dev/null +++ b/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/__init__.py b/__init__.py new file mode 100644 index 000000000..ca831ce0e --- /dev/null +++ b/__init__.py @@ -0,0 +1,47 @@ +import asyncio + +from environs import Env +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") + + +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: F401,F403 +from .views_api import * # noqa: F401,F403 + + +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/__pycache__/__init__.cpython-39.pyc b/__pycache__/__init__.cpython-39.pyc new file mode 100644 index 000000000..8aa5e09dc Binary files /dev/null and b/__pycache__/__init__.cpython-39.pyc differ diff --git a/__pycache__/crud.cpython-39.pyc b/__pycache__/crud.cpython-39.pyc new file mode 100644 index 000000000..c1cb40c0f Binary files /dev/null and b/__pycache__/crud.cpython-39.pyc differ diff --git a/__pycache__/ledger.cpython-39.pyc b/__pycache__/ledger.cpython-39.pyc new file mode 100644 index 000000000..e246f5d98 Binary files /dev/null and b/__pycache__/ledger.cpython-39.pyc differ diff --git a/__pycache__/migrations.cpython-39.pyc b/__pycache__/migrations.cpython-39.pyc new file mode 100644 index 000000000..6c8ed9611 Binary files /dev/null and b/__pycache__/migrations.cpython-39.pyc differ diff --git a/__pycache__/mint.cpython-39.pyc b/__pycache__/mint.cpython-39.pyc new file mode 100644 index 000000000..98b797394 Binary files /dev/null and b/__pycache__/mint.cpython-39.pyc differ diff --git a/__pycache__/mint_helper.cpython-39.pyc b/__pycache__/mint_helper.cpython-39.pyc new file mode 100644 index 000000000..3e3e5ed6d Binary files /dev/null and b/__pycache__/mint_helper.cpython-39.pyc differ diff --git a/__pycache__/models.cpython-39.pyc b/__pycache__/models.cpython-39.pyc new file mode 100644 index 000000000..baaa34e23 Binary files /dev/null and b/__pycache__/models.cpython-39.pyc differ diff --git a/__pycache__/tasks.cpython-39.pyc b/__pycache__/tasks.cpython-39.pyc new file mode 100644 index 000000000..d276a2d0a Binary files /dev/null and b/__pycache__/tasks.cpython-39.pyc differ diff --git a/__pycache__/views.cpython-39.pyc b/__pycache__/views.cpython-39.pyc new file mode 100644 index 000000000..de08d20df Binary files /dev/null and b/__pycache__/views.cpython-39.pyc differ diff --git a/__pycache__/views_api.cpython-39.pyc b/__pycache__/views_api.cpython-39.pyc new file mode 100644 index 000000000..ff37aa0c7 Binary files /dev/null and b/__pycache__/views_api.cpython-39.pyc differ diff --git a/config.json b/config.json new file mode 100644 index 000000000..14ff17439 --- /dev/null +++ b/config.json @@ -0,0 +1,7 @@ +{ + "name": "Cashu", + "short_description": "Ecash mint and wallet", + "tile": "/cashu/static/image/cashu.png", + "contributors": ["calle", "vlad", "arcbtc"], + "hidden": false +} diff --git a/crud.py b/crud.py new file mode 100644 index 000000000..e27cc98cc --- /dev/null +++ b/crud.py @@ -0,0 +1,51 @@ +from typing import List, Optional, Union + +from . import db +from .models import Cashu + + +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/extension.pem b/extension.pem new file mode 100644 index 000000000..13b6d6e95 --- /dev/null +++ b/extension.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCqdYhs1zok/3Es +uSycCn0YKGXaXO9Vbhb1Aol1vzYc0UkQseDrGCiEF/0D5Vol6n3aqsZabKfQC46e +ylEs+HWUYStsDApkSsyrlT+38nNYFtbCGNzf3lFqJxaOKrelCmhiYc3vzJc4yHpp +OaWtuWDlx3bWnJSMi8VF6UkqAtZrlLTrx45Wd/X2lPh5V3hHbwQy+BJ0kGOJuNWO +iMg4PUtaCVa8QQQSSdbXzLeMnkDXJRgGr6tEaFbZWCmQBOYIrmYcXTa4xSg+SaLa +fBqGGY5CFa2mnGarhQ9I6/Or7KES4CgyIewuJSC4VZQ9WNFTeWRJmwF59LQIB1B+ +Su61Ds5vAgMBAAECggEAUTUJN4EZzEJ9smbNn9FHY1jrnSolQzfdbAA3aIcdyoDS +JEf59UZfFv8upMqBNgsOxjwRn0agc/btjSypm5kKuxPH0gOyd7FC7IOABaxV0yOe +UH3zzBtoAgB5OGWVc+UPcb9SkumPKZgo6TRzf94PEakDLp8ODh8QbowDNSaA7GL2 +FXvSgcxtLFv24/wMj7MPtUJDPPzhzRafe097RAJKi9ig33GpiohVg7HWxBEt/m2q +WPJqV1izIYVuyUV1NN2qV/7KP35vqGu3sMyuKjmz33+7G5OUzxoixFp1hx1iQwNM +QmzrNjKgGKPOvI169K73LtT6GfcAcVyvudPioJi2oQKBgQDlvEg79bXuV72OMkDI +LINTk0Pncs5fJMhsPrbhvAbi8C9qH3j/e3uh4/f/RZVy8PZFe2YsmJo6cE60t445 +oejGpj0L9gB+ptWQa+6tJ0vO2DFrGz7siBDQDK08bBfE0FRAYYH1oy81oZihAwh/ +ypUWgxAAkFIDG1b8IYozUwlgaQKBgQC98mWG1ypNFDKU5ffFmxzCermXhe4sIOny +E9Na51lMuzp1HVOHqYio27KGWB9NhFnhCRdzq2csJrdL5GqNBzF3mVc7Wp1QA9Iz +GAN1BWYsObtffmePI73UOtdOf3vWRWt9xFDw07CuPUJoZLxCO8DE2R8TfBEZpMVx +L99xZ4ldFwKBgCCCZk5l1Nrne6F1pwksk2DuQ/uqmV2JXPtEXKjQ7MCMcIk57uto +U/RyyBNNi3p6cr8eWTUSWzDN17VnDC9HpCMIPzdEFonVnf8T5vCF0QpyOXwInPzn +vElUoecc/LgTKvj5U2SpNPMH7QtL3JYI2tQWs9sAkTpzUfnX3TCqHXeRAoGAGeEJ +phpwn/nNfm3qmRiuDW0tFhDgR1DgruO2mx8EEqk7qLMrK0OU4/Sy2QzujuAmYrub +X14N+3z24jcnsTk2qyXQNbz9y+YJYH64Edx/dC0Vc0hEkcL73Qt7RC2oUKQ6pfL2 +WClvW/3qq1B2OcvsUqWGT2BK8CR75hTwq4HAvFcCgYB0Cuv/cHo0iLzvCX2LwIiq +N38udO9oYa2VV/Ny4lf/oWFyLuL2syNJnW6fhqZk3ACVbIxQ4t85IJ+kKSKgSBXV +9p3kAo93/sP+Qd3yWK/2kc8fwQXPQWhEynmS0c2LmfmA6h+JlPUG+HHhXwEMcplc +iuvTCsY3KzPtBZgm8hFIKg== +-----END PRIVATE KEY----- diff --git a/extension/128.png b/extension/128.png new file mode 100644 index 000000000..35c4fdf0e Binary files /dev/null and b/extension/128.png differ diff --git a/extension/16.png b/extension/16.png new file mode 100644 index 000000000..3bb890c50 Binary files /dev/null and b/extension/16.png differ diff --git a/extension/32.png b/extension/32.png new file mode 100644 index 000000000..231239c5f Binary files /dev/null and b/extension/32.png differ diff --git a/extension/embedder.html b/extension/embedder.html new file mode 100644 index 000000000..851c5bb2d --- /dev/null +++ b/extension/embedder.html @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 000000000..a4fa9a41e --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,19 @@ +{ + "description": "Cashu ecash wallet", + "icons": {"128": "128.png", "32": "32.png", "16": "16.png"}, + "manifest_version": 3, + "name": "Cashu", + "version": "0.1", + "action": { + "default_icon": "32.png", + "default_popup": "embedder.html", + "default_title": "Cashu" + }, + "permissions": [ + "unlimitedStorage", + "storage" + ], + "content_security_policy": { + "extension_pages": "default-src 'self'" + } +} \ No newline at end of file diff --git a/migrations.py b/migrations.py new file mode 100644 index 000000000..b54c41087 --- /dev/null +++ b/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/models.py b/models.py new file mode 100644 index 000000000..84f28c2bc --- /dev/null +++ b/models.py @@ -0,0 +1,147 @@ +from sqlite3 import Row +from typing import List + +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/static/image/cashu.png b/static/image/cashu.png new file mode 100644 index 000000000..e90611fc0 Binary files /dev/null and b/static/image/cashu.png differ diff --git a/static/js/base64.js b/static/js/base64.js new file mode 100644 index 000000000..b150882f7 --- /dev/null +++ b/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/static/js/dhke.js b/static/js/dhke.js new file mode 100644 index 000000000..cebef240c --- /dev/null +++ b/static/js/dhke.js @@ -0,0 +1,31 @@ +async function hashToCurve(secretMessage) { + let point + while (!point) { + const hash = await nobleSecp256k1.utils.sha256(secretMessage) + const hashHex = nobleSecp256k1.utils.bytesToHex(hash) + const pointX = '02' + hashHex + try { + point = nobleSecp256k1.Point.fromHex(pointX) + } catch (error) { + secretMessage = await nobleSecp256k1.utils.sha256(secretMessage) + } + } + return point +} + +async function step1Alice(secretMessage) { + secretMessage = uint8ToBase64.encode(secretMessage) + secretMessage = new TextEncoder().encode(secretMessage) + const Y = await hashToCurve(secretMessage) + const r_bytes = nobleSecp256k1.utils.randomPrivateKey() + const r = bytesToNumber(r_bytes) + const P = nobleSecp256k1.Point.fromPrivateKey(r) + const B_ = Y.add(P) + return {B_: B_.toHex(true), r: nobleSecp256k1.utils.bytesToHex(r_bytes)} +} + +function step3Alice(C_, r, A) { + const rInt = bytesToNumber(r) + const C = C_.subtract(A.multiply(rInt)) + return C +} diff --git a/static/js/noble-secp256k1.js b/static/js/noble-secp256k1.js new file mode 100644 index 000000000..6a6bd4417 --- /dev/null +++ b/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/static/js/utils.js b/static/js/utils.js new file mode 100644 index 000000000..cf852b58b --- /dev/null +++ b/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/tasks.py b/tasks.py new file mode 100644 index 000000000..982d3ac18 --- /dev/null +++ b/tasks.py @@ -0,0 +1,31 @@ +import asyncio + +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 + + +async def startup_cashu_mint(): + await migrate_databases(db, migrations) + await ledger.load_used_proofs() + await ledger.init_keysets(autosave=False) + + +async def wait_for_paid_invoices(): + invoice_queue = asyncio.Queue() + register_invoice_listener(invoice_queue) + + while True: + payment = await invoice_queue.get() + await on_invoice_paid(payment) + + +async def on_invoice_paid(payment: Payment) -> None: + if payment.extra.get("tag") != "cashu": + return + + return diff --git a/templates/cashu/_api_docs.html b/templates/cashu/_api_docs.html new file mode 100644 index 000000000..f7bb19f60 --- /dev/null +++ b/templates/cashu/_api_docs.html @@ -0,0 +1,80 @@ + + + + diff --git a/templates/cashu/_cashu.html b/templates/cashu/_cashu.html new file mode 100644 index 000000000..370e9eb4a --- /dev/null +++ b/templates/cashu/_cashu.html @@ -0,0 +1,28 @@ + + + +

Create Cashu ecash mints and wallets.

+ Created by + arcbtc, + vlad, + calle. +
+
+
diff --git a/templates/cashu/index.html b/templates/cashu/index.html new file mode 100644 index 000000000..2599669cf --- /dev/null +++ b/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/templates/cashu/mint.html b/templates/cashu/mint.html new file mode 100644 index 000000000..a6959ec24 --- /dev/null +++ b/templates/cashu/mint.html @@ -0,0 +1,92 @@ +{% extends "public.html" %} {% block page %} +
+
+ + +
+ +

{{ mint_name }}

+ + click to 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
+ Cashu is still experimental and in active development. There are + likely bugs in this implementation so please use this with caution. We + hold no responsibility for people losing access to funds. Use at your + own risk! +

+
+
+
+
+ +{% endblock %} {% block scripts %} + + + +{% endblock %} diff --git a/templates/cashu/wallet.html b/templates/cashu/wallet.html new file mode 100644 index 000000000..9638f347e --- /dev/null +++ b/templates/cashu/wallet.html @@ -0,0 +1,2992 @@ +{% extends "public.html" %} {% block toolbar_title %} {% raw %} Cashu wallet {% +endraw %} {% endblock %} {% block footer %}{% endblock %} {% block +page_container %} + + +
+
+ + +
+
+
+

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

+
+
+ + +
+
+
+ + + +
+
+ Get Ecash +
+
+
+ + Send Ecash +
+
+ + + + + + + + + + + + +
+ + + + Mints + You can connect your wallet to multiple Cashu mints. + Enter a mint URL and select the mint your want to + use. + + + + +
+ {% raw %} + + + + + + + + + {{mint.url}} + + + + + + + + + + + {% endraw %} + +
+
+
+
+
+ + + + + + + +
+
+
+ + + + Multimint Swaps + Swap funds from one mint to another via Lightning. + Warning: this feature is still experimental and could + behave in unexpected ways! + + + + + + + + + + + Swap + + +
+
+ + + + + {% raw %} + + {% endraw %} + + + + + + + + {% raw %} + + {% endraw %} + + + + + + + + {% raw %} + + {% endraw %} + + +
+
+
+ +
+
+
+ Create invoice + +
+ +
+
+ Warning + Download wallet backup + InstallInstall Cashu +
+
+
+ Pay invoice + +
+
+
+
+ + + +
+ + + + + + + + +
+ + + +
+ {% raw %} +
+ Pay {{ payInvoiceData.invoice.fsat }}{% endraw %} + {{LNBITS_DENOMINATION}} {% raw %} +
+ +

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

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

+ {{ payInvoiceData.domain }} is requesting {{ + payInvoiceData.lnurlpay.maxSendable | msatoshiFormat }} {% + endraw %} {{LNBITS_DENOMINATION}} {% raw %} + +

+

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

+ +
+

+ {{ payInvoiceData.lnurlpay.description }} +

+

+ +

+
+
+
+ {% endraw %} + +
+
+ +
+
+
+ Send + Cancel +
+
+
+
+ + + +
+ Enter + + + Close +
+
+
+ + + +
+ + Cancel + +
+
+
+
+
+ + + +
+ +
+
+ Cancel +
+
+
+ + + + + + + + Cashu + wallet + + +

Please take a moment to read the following information.

+ +

+ Open this wallet on your device's native browser + Cashu stores your ecash on your device locally. For the best + experience, use this wallet with your device's native web browser + (for example Safari for iOS, Chrome for Android). +

+

+ Add to home screen. + 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 software is in BETA! We hold no + responsibility for people losing access to funds. Use at your own + risk! 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. +

+
+ Install Cashu + Copy URL + Continue +
+
+
+
+ + + +
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 software 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 + + Close +
+
+
+ + +
Do you trust this mint?
+

+ A Cashu mint does not know about your financial activity but it + controls your funds. Make sure that you trust the operator of this + mint. +

+ +
+ Add mint + Cancel +
+
+
+
+
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + + + + + +{% endblock %} diff --git a/templates/cashu/wallet2.html b/templates/cashu/wallet2.html new file mode 100644 index 000000000..f628a76b0 --- /dev/null +++ b/templates/cashu/wallet2.html @@ -0,0 +1,2622 @@ +{% extends "public.html" %} {% block toolbar_title %} {% raw %} Cashu {% endraw +%} - {{mint_name}} {% endblock %} {% block footer %}{% endblock %} {% block +page_container %} + + +
+
+ + +
+
+
+ Create invoice + +
+
+

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

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

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

+
+
+
+
+
+ + + +
+
+ Get Ecash +
+
+
+ + Pay Ecash +
+
+ + + + + + + + + + + + +
+ + + + Cashu mints + {{item}} + You have to connect your wallet to one or more + mints. + + + + Your mints +
+ {% raw %} + + + + + {{mint.url}} + + {{mint.balance}} {{tickerShort}} + + + + + + {% endraw %} + +
+
+
+
+
+ + + + + + + +
+
+
+ + + + + {% 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 + + + Close +
+
+
+
+
+
+{% endblock %} {% block styles %} + +{% endblock %} {% block scripts %} + + + + + +{% endblock %} diff --git a/views.py b/views.py new file mode 100644 index 000000000..09d314c36 --- /dev/null +++ b/views.py @@ -0,0 +1,253 @@ +from http import HTTPStatus +from typing import Optional + +from fastapi import Depends, Request +from fastapi.templating import Jinja2Templates +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + +from lnbits.core.models import User +from lnbits.decorators import check_user_exists + +from . import 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), +): + return cashu_renderer().TemplateResponse( + "cashu/index.html", {"request": request, "user": user.dict()} + ) + + +@cashu_ext.get("/wallet") +async def wallet(request: Request, mint_id: Optional[str] = None): + if mint_id is not None: + cashu = await get_cashu(mint_id) + if not cashu: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist." + ) + manifest_url = f"/cashu/manifest/{mint_id}.webmanifest" + mint_name = cashu.name + else: + manifest_url = "/cashu/cashu.webmanifest" + mint_name = "Cashu mint" + + return cashu_renderer().TemplateResponse( + "cashu/wallet.html", + { + "request": request, + "web_manifest": manifest_url, + "mint_name": mint_name, + }, + ) + + +@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="Mint does not exist." + ) + return cashu_renderer().TemplateResponse( + "cashu/mint.html", + {"request": request, "mint_id": mintID}, + ) + + +@cashu_ext.get("/manifest/{cashu_id}.webmanifest") +async def manifest_lnbits(cashu_id: str): + cashu = await get_cashu(cashu_id) + if not cashu: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist." + ) + + return get_manifest(cashu_id, cashu.name) + + +@cashu_ext.get("/cashu.webmanifest") +async def manifest(): + return get_manifest() + + +def get_manifest(mint_id: Optional[str] = None, mint_name: Optional[str] = None): + manifest_name = "Cashu" + if mint_name: + manifest_name += " - " + mint_name + manifest_url = "/cashu/wallet" + if mint_id: + manifest_url += "?mint_id=" + mint_id + + return { + "short_name": "Cashu", + "name": manifest_name, + "icons": [ + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/512x512.png", + "type": "image/png", + "sizes": "512x512", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/96x96.png", + "type": "image/png", + "sizes": "96x96", + }, + ], + "id": manifest_url, + "start_url": manifest_url, + "background_color": "#1F2234", + "description": "Cashu ecash wallet", + "display": "standalone", + "scope": "/cashu/", + "theme_color": "#1F2234", + "protocol_handlers": [ + {"protocol": "web+cashu", "url": "&recv_token=%s"}, + {"protocol": "web+lightning", "url": "&lightning=%s"}, + ], + "shortcuts": [ + { + "name": manifest_name, + "short_name": "Cashu", + "description": manifest_name, + "url": manifest_url, + "icons": [ + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/512x512.png", + "sizes": "512x512", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/192x192.png", + "sizes": "192x192", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/144x144.png", + "sizes": "144x144", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/96x96.png", + "sizes": "96x96", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/72x72.png", + "sizes": "72x72", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/48x48.png", + "sizes": "48x48", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/16x16.png", + "sizes": "16x16", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/20x20.png", + "sizes": "20x20", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/29x29.png", + "sizes": "29x29", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/32x32.png", + "sizes": "32x32", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/40x40.png", + "sizes": "40x40", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/50x50.png", + "sizes": "50x50", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/57x57.png", + "sizes": "57x57", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/58x58.png", + "sizes": "58x58", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/60x60.png", + "sizes": "60x60", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/64x64.png", + "sizes": "64x64", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/72x72.png", + "sizes": "72x72", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/76x76.png", + "sizes": "76x76", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/80x80.png", + "sizes": "80x80", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/87x87.png", + "sizes": "87x87", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/100x100.png", + "sizes": "100x100", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/114x114.png", + "sizes": "114x114", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/120x120.png", + "sizes": "120x120", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/128x128.png", + "sizes": "128x128", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/144x144.png", + "sizes": "144x144", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/152x152.png", + "sizes": "152x152", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/167x167.png", + "sizes": "167x167", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/180x180.png", + "sizes": "180x180", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/192x192.png", + "sizes": "192x192", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/256x256.png", + "sizes": "256x256", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/512x512.png", + "sizes": "512x512", + }, + { + "src": "https://github.com/cashubtc/cashu-ui/raw/main/ui/icons/circle/1024x1024.png", + "sizes": "1024x1024", + }, + ], + } + ], + } diff --git a/views_api.py b/views_api.py new file mode 100644 index 000000000..682412a4e --- /dev/null +++ b/views_api.py @@ -0,0 +1,440 @@ +import math +from http import HTTPStatus +from typing import Dict, Union + +# -------- cashu imports +from cashu.core.base import ( + CheckFeesRequest, + CheckFeesResponse, + CheckSpendableRequest, + CheckSpendableResponse, + GetMeltResponse, + GetMintResponse, + Invoice, + PostMeltRequest, + PostMintRequest, + PostMintResponse, + PostSplitRequest, + PostSplitResponse, +) +from fastapi import Depends, Query +from loguru import logger +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.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 + +# WARNING: Do not set this to False in production! This will create +# tokens for free otherwise. This is for testing purposes only! + +LIGHTNING = True + +if not LIGHTNING: + logger.warning( + "Cashu: LIGHTNING is set False! That means that I will create ecash for free!" + ) + +######################################## +############### LNBITS MINTS ########### +######################################## + + +@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) +): + """ + 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), +): + """ + 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) +): + """ + 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}/keys/{idBase64Urlsafe}") +async def keyset_keys( + cashu_id: str = Query(None), idBase64Urlsafe: str = Query(None) +) -> dict[int, str]: + """ + Get the public keys of the mint of a specificy keyset id. + The id is encoded in base64_urlsafe and needs to be converted back to + normal base64 before it can be processed. + """ + + cashu: Union[Cashu, None] = await get_cashu(cashu_id) + + if not cashu: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Mint does not exist." + ) + + id = idBase64Urlsafe.replace("-", "+").replace("_", "/") + keyset = ledger.get_keyset(keyset_id=id) + return keyset + + +@cashu_ext.get("/api/v1/{cashu_id}/keysets", status_code=HTTPStatus.OK) +async def keysets(cashu_id: str = Query(None)) -> dict[str, list[str]]: + """Get the public keys of the mint""" + 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( + data: PostMintRequest, + cashu_id: str = Query(None), + payment_hash: str = Query(None), +) -> PostMintResponse: + """ + 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." + ) + + keyset = ledger.keysets.keysets[cashu.keyset_id] + + 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: + raise HTTPException( + status_code=HTTPStatus.PAYMENT_REQUIRED, + detail="Tokens already issued for this invoice.", + ) + + # set this invoice as issued + await ledger.crud.update_lightning_invoice( + db=ledger.db, hash=payment_hash, issued=True + ) + + status: PaymentStatus = await check_transaction_status( + cashu.wallet, payment_hash + ) + + try: + total_requested = sum([bm.amount for bm in data.outputs]) + if total_requested > invoice.amount: + raise HTTPException( + status_code=HTTPStatus.PAYMENT_REQUIRED, + detail=f"Requested amount too high: {total_requested}. Invoice amount: {invoice.amount}", + ) + + if not status.paid: + raise HTTPException( + status_code=HTTPStatus.PAYMENT_REQUIRED, detail="Invoice not paid." + ) + + promises = await ledger._generate_promises(B_s=data.outputs, keyset=keyset) + return PostMintResponse(promises=promises) + except (Exception, HTTPException) as e: + logger.debug(f"Cashu: /melt {str(e) or getattr(e, 'detail')}") + # unset issued flag because something went wrong + await ledger.crud.update_lightning_invoice( + db=ledger.db, hash=payment_hash, issued=False + ) + raise HTTPException( + status_code=getattr(e, "status_code") + or HTTPStatus.INTERNAL_SERVER_ERROR, + detail=str(e) or getattr(e, "detail"), + ) + else: + # only used for testing when LIGHTNING=false + promises = await ledger._generate_promises(B_s=data.outputs, keyset=keyset) + return PostMintResponse(promises=promises) + + +@cashu_ext.post("/api/v1/{cashu_id}/melt") +async def melt_coins( + payload: PostMeltRequest, 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.pr + + # !!!!!!! 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.", + ) + + # set proofs as pending + await ledger._set_proofs_pending(proofs) + + try: + await ledger._verify_proofs(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 + math.ceil(fees_msat / 1000), Exception( + f"Provided proofs ({total_provided} sats) not enough for Lightning payment ({amount + fees_msat} sats)." + ) + logger.debug(f"Cashu: Initiating payment of {total_provided} sats") + try: + await pay_invoice( + wallet_id=cashu.wallet, + payment_request=invoice, + description="Pay cashu invoice", + extra={"tag": "cashu", "cashu_name": cashu.name}, + ) + except Exception as e: + logger.debug(f"Cashu error paying invoice {invoice_obj.payment_hash}: {e}") + raise e + finally: + logger.debug( + f"Cashu: Wallet {cashu.wallet} checking PaymentStatus of {invoice_obj.payment_hash}" + ) + status: PaymentStatus = await check_transaction_status( + cashu.wallet, invoice_obj.payment_hash + ) + if status.paid is True: + logger.debug( + f"Cashu: Payment successful, invalidating proofs for {invoice_obj.payment_hash}" + ) + await ledger._invalidate_proofs(proofs) + else: + logger.debug(f"Cashu: Payment failed for {invoice_obj.payment_hash}") + except Exception as e: + logger.debug(f"Cashu: Exception: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail=f"Cashu: {str(e)}", + ) + finally: + logger.debug("Cashu: Unset pending") + # delete proofs from pending list + await ledger._unset_proofs_pending(proofs) + + return GetMeltResponse(paid=status.paid, preimage=status.preimage) + + +@cashu_ext.post("/api/v1/{cashu_id}/check") +async def check_spendable( + payload: CheckSpendableRequest, 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." + ) + spendableList = await ledger.check_spendable(payload.proofs) + return CheckSpendableResponse(spendable=spendableList) + + +@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=math.ceil(fees_msat / 1000)) + + +@cashu_ext.post("/api/v1/{cashu_id}/split") +async def split( + payload: PostSplitRequest, 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 + 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