init
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
+47
@@ -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))
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "Cashu",
|
||||
"short_description": "Ecash mint and wallet",
|
||||
"tile": "/cashu/static/image/cashu.png",
|
||||
"contributors": ["calle", "vlad", "arcbtc"],
|
||||
"hidden": false
|
||||
}
|
||||
@@ -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,))
|
||||
@@ -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-----
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 828 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
background: #350a60;
|
||||
margin: 0;
|
||||
width: 380px;
|
||||
height: 595px;
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe
|
||||
id="iframe"
|
||||
src="https://cashu.calle.wtf:5001/cashu/wallet?mint_id=W358Nzvmb7iuQpt6mXArKx"
|
||||
></iframe>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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'"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
"""
|
||||
)
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -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
|
||||
})({})
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,80 @@
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="swap_vertical_circle"
|
||||
label="API info"
|
||||
:content-inset-level="0.5"
|
||||
>
|
||||
<q-btn flat label="Swagger API" type="a" href="../docs#/cashu"></q-btn>
|
||||
<!-- <q-expansion-item group="api" dense expand-separator label="List TPoS">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-blue">GET</span> /cashu/api/v1/mints</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 200 OK (application/json)
|
||||
</h5>
|
||||
<code>[<cashu_object>, ...]</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X GET {{ request.base_url }}cashu/api/v1/mints -H "X-Api-Key:
|
||||
<invoice_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item group="api" dense expand-separator label="Create a TPoS">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code><span class="text-green">POST</span> /cashu/api/v1/mints</code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <invoice_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Body (application/json)</h5>
|
||||
<code
|
||||
>{"name": <string>, "currency": <string*ie USD*>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">
|
||||
Returns 201 CREATED (application/json)
|
||||
</h5>
|
||||
<code
|
||||
>{"currency": <string>, "id": <string>, "name":
|
||||
<string>, "wallet": <string>}</code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X POST {{ request.base_url }}cashu/api/v1/mints -d '{"name":
|
||||
<string>, "currency": <string>}' -H "Content-type:
|
||||
application/json" -H "X-Api-Key: <admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-expansion-item
|
||||
group="api"
|
||||
dense
|
||||
expand-separator
|
||||
label="Delete a TPoS"
|
||||
class="q-pb-md"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<code
|
||||
><span class="text-pink">DELETE</span>
|
||||
/cashu/api/v1/mints/<cashu_id></code
|
||||
>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Headers</h5>
|
||||
<code>{"X-Api-Key": <admin_key>}</code><br />
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Returns 204 NO CONTENT</h5>
|
||||
<code></code>
|
||||
<h5 class="text-caption q-mt-sm q-mb-none">Curl example</h5>
|
||||
<code
|
||||
>curl -X DELETE {{ request.base_url
|
||||
}}cashu/api/v1/mints/<cashu_id> -H "X-Api-Key:
|
||||
<admin_key>"
|
||||
</code>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item> -->
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,28 @@
|
||||
<q-expansion-item group="extras" icon="info" label="About">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<p>Create Cashu ecash mints and wallets.</p>
|
||||
<small
|
||||
>Created by
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/arcbtc"
|
||||
target="_blank"
|
||||
>arcbtc</a
|
||||
>,
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/motorina0"
|
||||
target="_blank"
|
||||
>vlad</a
|
||||
>,
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://github.com/calle"
|
||||
target="_blank"
|
||||
>calle</a
|
||||
>.</small
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
@@ -0,0 +1,367 @@
|
||||
{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
|
||||
%} {% block page %}
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<b>Cashu mint and wallet</b>
|
||||
<p></p>
|
||||
<p>
|
||||
Here you can create multiple cashu mints that you can share. Each mint
|
||||
can service many users but all ecash tokens of a mint are only valid
|
||||
inside that mint and not across different mints. To exchange funds
|
||||
between mints, use Lightning payments.
|
||||
</p>
|
||||
<b>Important</b>
|
||||
<p></p>
|
||||
<p>
|
||||
If you are the operator of this LNbits instance, make sure to set
|
||||
CASHU_PRIVATE_KEY="randomkey" in your configuration file. Do not
|
||||
create mints before setting the key and do not change the key once
|
||||
set.
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="row items-center no-wrap q-mb-md">
|
||||
<div class="col">
|
||||
<h5 class="text-subtitle1 q-my-none">Mints</h5>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<q-btn flat color="grey" @click="exportCSV">Export to CSV</q-btn>
|
||||
</div>
|
||||
</div>
|
||||
<q-table
|
||||
dense
|
||||
flat
|
||||
:data="cashus"
|
||||
row-key="id"
|
||||
:columns="cashusTable.columns"
|
||||
:pagination.sync="cashusTable.pagination"
|
||||
>
|
||||
{% raw %}
|
||||
<template v-slot:header="props">
|
||||
<q-tr :props="props">
|
||||
<q-th auto-width></q-th>
|
||||
<q-th v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ col.label }}
|
||||
</q-th>
|
||||
<q-th auto-width></q-th>
|
||||
</q-tr>
|
||||
</template>
|
||||
|
||||
<template v-slot:body="props">
|
||||
<q-tr :props="props">
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="account_balance_wallet"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'wallet/?' + 'mint_id=' + props.row.id"
|
||||
target="_blank"
|
||||
><q-tooltip>Shareable wallet</q-tooltip></q-btn
|
||||
>
|
||||
|
||||
<q-btn
|
||||
unelevated
|
||||
dense
|
||||
size="xs"
|
||||
icon="account_balance"
|
||||
:color="($q.dark.isActive) ? 'grey-7' : 'grey-5'"
|
||||
type="a"
|
||||
:href="'mint/' + props.row.id"
|
||||
target="_blank"
|
||||
><q-tooltip>Shareable mint page</q-tooltip></q-btn
|
||||
>
|
||||
</q-td>
|
||||
<q-td v-for="col in props.cols" :key="col.name" :props="props">
|
||||
{{ (col.name == 'tip_options' && col.value ?
|
||||
JSON.parse(col.value).join(", ") : col.value) }}
|
||||
</q-td>
|
||||
<q-td auto-width>
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
size="xs"
|
||||
@click="deleteMint(props.row.id)"
|
||||
icon="cancel"
|
||||
color="pink"
|
||||
></q-btn>
|
||||
</q-td>
|
||||
</q-tr>
|
||||
</template>
|
||||
{% endraw %}
|
||||
</q-table>
|
||||
<q-btn
|
||||
class="q-pt-l"
|
||||
unelevated
|
||||
color="primary"
|
||||
@click="formDialog.show = true"
|
||||
>New Mint</q-btn
|
||||
>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-5 q-gutter-y-md">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<h6 class="text-subtitle1 q-my-none">{{SITE_TITLE}} Cashu extension</h6>
|
||||
</q-card-section>
|
||||
<q-card-section class="q-pa-none">
|
||||
<q-separator></q-separator>
|
||||
<q-list>
|
||||
{% include "cashu/_api_docs.html" %}
|
||||
<q-separator></q-separator>
|
||||
{% include "cashu/_cashu.html" %}
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<q-dialog v-model="formDialog.show" position="top" @hide="closeFormDialog">
|
||||
<q-card class="q-pa-lg q-pt-xl" style="width: 500px">
|
||||
<q-form @submit="createMint" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.name"
|
||||
label="Mint Name"
|
||||
placeholder="Cashu Mint"
|
||||
></q-input>
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="formDialog.data.wallet"
|
||||
:options="g.user.walletOptions"
|
||||
label="Cashu wallet *"
|
||||
></q-select>
|
||||
<!-- <q-toggle
|
||||
v-model="toggleAdvanced"
|
||||
label="Show advanced options"
|
||||
></q-toggle>
|
||||
<div v-show="toggleAdvanced">
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<q-checkbox
|
||||
v-model="formDialog.data.fraction"
|
||||
color="primary"
|
||||
label="sats/coins?"
|
||||
>
|
||||
<q-tooltip
|
||||
>Use with hedging extension to create a stablecoin!</q-tooltip
|
||||
>
|
||||
</q-checkbox>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<q-input
|
||||
v-if="!formDialog.data.fraction"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.trim="formDialog.data.cost"
|
||||
label="Sat coin cost (optional)"
|
||||
value="1"
|
||||
type="number"
|
||||
></q-input>
|
||||
<q-input
|
||||
v-if="!formDialog.data.fraction"
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.tickershort"
|
||||
label="Ticker shorthand"
|
||||
placeholder="sats"
|
||||
#
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
<q-input
|
||||
class="q-mt-md"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.trim="formDialog.data.maxsats"
|
||||
label="Maximum mint liquidity (optional)"
|
||||
placeholder="∞"
|
||||
></q-input>
|
||||
<q-input
|
||||
class="q-mt-md"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.trim="formDialog.data.coins"
|
||||
label="Coins that 'exist' in mint (optional)"
|
||||
placeholder="∞"
|
||||
></q-input>
|
||||
</div> -->
|
||||
<div class="row q-mt-md">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:disable="formDialog.data.wallet == null || formDialog.data.name == null"
|
||||
type="submit"
|
||||
>Create Mint
|
||||
</q-btn>
|
||||
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
|
||||
>Cancel</q-btn
|
||||
>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %} {{ window_vars(user) }}
|
||||
<script>
|
||||
var mapMint = function (obj) {
|
||||
obj.date = Quasar.utils.date.formatDate(
|
||||
new Date(obj.time * 1000),
|
||||
'YYYY-MM-DD HH:mm'
|
||||
)
|
||||
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.amount)
|
||||
obj.cashu = ['/cashu/', obj.id].join('')
|
||||
return obj
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
cashus: [],
|
||||
hostname: location.protocol + '//' + location.host + '/cashu/mint/',
|
||||
toggleAdvanced: false,
|
||||
cashusTable: {
|
||||
columns: [
|
||||
{name: 'id', align: 'left', label: 'Mint ID', field: 'id'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
// {
|
||||
// name: 'tickershort',
|
||||
// align: 'left',
|
||||
// label: 'Ticker',
|
||||
// field: 'tickershort'
|
||||
// },
|
||||
{
|
||||
name: 'wallet',
|
||||
align: 'left',
|
||||
label: 'Mint wallet',
|
||||
field: 'wallet'
|
||||
}
|
||||
// {
|
||||
// name: 'fraction',
|
||||
// align: 'left',
|
||||
// label: 'Using fraction',
|
||||
// field: 'fraction'
|
||||
// },
|
||||
// {
|
||||
// name: 'maxsats',
|
||||
// align: 'left',
|
||||
// label: 'Max Sats',
|
||||
// field: 'maxsats'
|
||||
// },
|
||||
// {
|
||||
// name: 'coins',
|
||||
// align: 'left',
|
||||
// label: 'No. of coins',
|
||||
// field: 'coins'
|
||||
// }
|
||||
],
|
||||
pagination: {
|
||||
rowsPerPage: 10
|
||||
}
|
||||
},
|
||||
formDialog: {
|
||||
show: false,
|
||||
data: {fraction: false}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeFormDialog: function () {
|
||||
this.formDialog.data = {}
|
||||
},
|
||||
getMints: function () {
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'GET',
|
||||
'/cashu/api/v1/mints?all_wallets=true',
|
||||
this.g.user.wallets[0].inkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cashus = response.data.map(function (obj) {
|
||||
return mapMint(obj)
|
||||
})
|
||||
})
|
||||
},
|
||||
createMint: function () {
|
||||
if (this.formDialog.data.maxliquid == null) {
|
||||
this.formDialog.data.maxliquid = 0
|
||||
}
|
||||
var data = {
|
||||
name: this.formDialog.data.name,
|
||||
tickershort: this.formDialog.data.tickershort,
|
||||
maxliquid: this.formDialog.data.maxliquid
|
||||
}
|
||||
var self = this
|
||||
|
||||
LNbits.api
|
||||
.request(
|
||||
'POST',
|
||||
'/cashu/api/v1/mints',
|
||||
_.findWhere(this.g.user.wallets, {id: this.formDialog.data.wallet})
|
||||
.inkey,
|
||||
data
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cashus.push(mapMint(response.data))
|
||||
self.formDialog.show = false
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
},
|
||||
deleteMint: function (cashuId) {
|
||||
var self = this
|
||||
var cashu = _.findWhere(this.cashus, {id: cashuId})
|
||||
console.log(cashu)
|
||||
|
||||
LNbits.utils
|
||||
.confirmDialog(
|
||||
"Are you sure you want to delete this Mint? This mint's users will not be able to redeem their tokens!"
|
||||
)
|
||||
.onOk(function () {
|
||||
LNbits.api
|
||||
.request(
|
||||
'DELETE',
|
||||
'/cashu/api/v1/mints/' + cashuId,
|
||||
_.findWhere(self.g.user.wallets, {id: cashu.wallet}).adminkey
|
||||
)
|
||||
.then(function (response) {
|
||||
self.cashus = _.reject(self.cashus, function (obj) {
|
||||
return obj.id == cashuId
|
||||
})
|
||||
})
|
||||
.catch(function (error) {
|
||||
LNbits.utils.notifyApiError(error)
|
||||
})
|
||||
})
|
||||
},
|
||||
exportCSV: function () {
|
||||
LNbits.utils.exportCSV(this.cashusTable.columns, this.cashus)
|
||||
}
|
||||
},
|
||||
created: function () {
|
||||
if (this.g.user.wallets.length) {
|
||||
this.getMints()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "public.html" %} {% block page %}
|
||||
<div class="row q-col-gutter-md justify-center">
|
||||
<div class="col-12 col-md-7 col-lg-6 q-gutter-y-md">
|
||||
<q-card class="q-pa-lg q-mb-xl">
|
||||
<q-card-section class="q-pa-none">
|
||||
<center>
|
||||
<q-img
|
||||
src="/cashu/static/image/cashu.png"
|
||||
spinner-color="white"
|
||||
style="max-width: 20%"
|
||||
></q-img>
|
||||
<h4 class="q-mt-sm q-mb-md">{{ mint_name }}</h4>
|
||||
<!-- <a class="text-secondary">Mint URL: {{testfield}} </a> <br /> -->
|
||||
<a
|
||||
class="text-secondary"
|
||||
class="q-my-xl text-white"
|
||||
style="font-size: 1.5rem"
|
||||
href="../wallet?mint_id={{ mint_id }}"
|
||||
>click to open wallet</a
|
||||
>
|
||||
</center>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card class="q-pa-lg q-mb-xl">
|
||||
<q-card-section class="q-pa-none">
|
||||
<h5 class="q-my-md">Read the following carefully!</h5>
|
||||
<p>
|
||||
This is a
|
||||
<a
|
||||
class="text-secondary"
|
||||
href="https://cashu.space/"
|
||||
style="color: white"
|
||||
target="”_blank”"
|
||||
>Cashu</a
|
||||
>
|
||||
mint. Cashu is an ecash system for Bitcoin.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Open this page in your native browser</strong><br />
|
||||
Before you continue to the wallet, make sure to open this page in your
|
||||
device's native browser application (Safari for iOS, Chrome for
|
||||
Android). Do not use Cashu in an embedded browser that opens when you
|
||||
click a link in a messenger.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Add wallet to home screen</strong><br />
|
||||
You can add Cashu to your home screen as a progressive web app (PWA).
|
||||
After opening the wallet in your browser (click the link above), on
|
||||
Android (Chrome), click the menu at the upper right. On iOS (Safari),
|
||||
click the share button. Now press the Add to Home screen button.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Backup your wallet</strong><br />
|
||||
Ecash is a bearer asset. That means losing access to your wallet will
|
||||
make you lose your funds. The wallet stores ecash tokens on your
|
||||
device's database. If you lose the link or delete your your data
|
||||
without backing up, you will lose your tokens. Press the Backup button
|
||||
in the wallet to download a copy of your tokens.
|
||||
</p>
|
||||
<p>
|
||||
<strong>This service is in BETA</strong> <br />
|
||||
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!
|
||||
</p>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
|
||||
<script>
|
||||
Vue.component(VueQrcode.name, VueQrcode)
|
||||
|
||||
new Vue({
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
data: function () {
|
||||
return {
|
||||
testfield: 'asd',
|
||||
mintURL: {
|
||||
location: window.location,
|
||||
base_url: location.protocol + '//' + location.host + location.pathname
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
+440
@@ -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
|
||||
Reference in New Issue
Block a user