Files
nutshell/cashu/wallet/crud.py
callebtc a0ef44dba0 Blind authentication (#675)
* auth server

* cleaning up

* auth ledger class

* class variables -> instance variables

* annotations

* add models and api route

* custom amount and api prefix

* add auth db

* blind auth token working

* jwt working

* clean up

* JWT works

* using openid connect server

* use oauth server with password flow

* new realm

* add keycloak docker

* hopefully not garbage

* auth works

* auth kinda working

* fix cli

* auth works for send and receive

* pass auth_db to Wallet

* auth in info

* refactor

* fix supported

* cache mint info

* fix settings and endpoints

* add description to .env.example

* track changes for openid connect client

* store mint in db

* store credentials

* clean up v1_api.py

* load mint info into auth wallet

* fix first login

* authenticate if refresh token fails

* clear auth also middleware

* use regex

* add cli command

* pw works

* persist keyset amounts

* add errors.py

* do not start auth server if disabled in config

* upadte poetry

* disvoery url

* fix test

* support device code flow

* adopt latest spec changes

* fix code flow

* mint max bat dynamic

* mypy ignore

* fix test

* do not serialize amount in authproof

* all auth flows working

* fix tests

* submodule

* refactor

* test

* dont sleep

* test

* add wallet auth tests

* test differently

* test only keycloak for now

* fix creds

* daemon

* fix test

* install everything

* install jinja

* delete wallet for every test

* auth: use global rate limiter

* test auth rate limit

* keycloak hostname

* move keycloak test data

* reactivate all tests

* add readme

* load proofs

* remove unused code

* remove unused code

* implement change suggestions by ok300

* add error codes

* test errors
2025-01-29 22:48:51 -06:00

637 lines
16 KiB
Python

import json
import time
from typing import Any, Dict, List, Optional, Tuple
from ..core.base import (
MeltQuote,
MeltQuoteState,
MintQuote,
MintQuoteState,
Proof,
WalletKeyset,
WalletMint,
)
from ..core.db import Connection, Database
async def store_proof(
proof: Proof,
db: Database,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
INSERT INTO proofs
(id, amount, C, secret, time_created, derivation_path, dleq, mint_id, melt_id)
VALUES (:id, :amount, :C, :secret, :time_created, :derivation_path, :dleq, :mint_id, :melt_id)
""",
{
"id": proof.id,
"amount": proof.amount,
"C": str(proof.C),
"secret": str(proof.secret),
"time_created": int(time.time()),
"derivation_path": proof.derivation_path,
"dleq": json.dumps(proof.dleq.dict()) if proof.dleq else "",
"mint_id": proof.mint_id,
"melt_id": proof.melt_id,
},
)
async def get_proofs(
*,
db: Database,
id: Optional[str] = "",
melt_id: str = "",
mint_id: str = "",
table: str = "proofs",
conn: Optional[Connection] = None,
):
clauses = []
values: Dict[str, Any] = {}
if id:
clauses.append("id = :id")
values["id"] = id
if melt_id:
clauses.append("melt_id = :melt_id")
values["melt_id"] = melt_id
if mint_id:
clauses.append("mint_id = :mint_id")
values["mint_id"] = mint_id
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
rows = await (conn or db).fetchall(
f"""
SELECT * from {table}
{where}
""",
values,
)
return [Proof.from_dict(dict(r)) for r in rows] if rows else []
async def get_reserved_proofs(
db: Database,
conn: Optional[Connection] = None,
) -> List[Proof]:
rows = await (conn or db).fetchall(
"""
SELECT * from proofs
WHERE reserved
"""
)
return [Proof.from_dict(dict(r)) for r in rows]
async def invalidate_proof(
proof: Proof,
db: Database,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
DELETE FROM proofs
WHERE secret = :secret
""",
{"secret": str(proof["secret"])},
)
await (conn or db).execute(
"""
INSERT INTO proofs_used
(amount, C, secret, time_used, id, derivation_path, mint_id, melt_id)
VALUES (:amount, :C, :secret, :time_used, :id, :derivation_path, :mint_id, :melt_id)
""",
{
"amount": proof.amount,
"C": str(proof.C),
"secret": str(proof.secret),
"time_used": int(time.time()),
"id": proof.id,
"derivation_path": proof.derivation_path,
"mint_id": proof.mint_id,
"melt_id": proof.melt_id,
},
)
async def update_proof(
proof: Proof,
*,
reserved: Optional[bool] = None,
send_id: Optional[str] = None,
mint_id: Optional[str] = None,
melt_id: Optional[str] = None,
db: Optional[Database] = None,
conn: Optional[Connection] = None,
) -> None:
clauses = []
values: Dict[str, Any] = {}
if reserved is not None:
clauses.append("reserved = :reserved")
values["reserved"] = reserved
clauses.append("time_reserved = :time_reserved")
values["time_reserved"] = int(time.time())
if send_id is not None:
clauses.append("send_id = :send_id")
values["send_id"] = send_id
if mint_id is not None:
clauses.append("mint_id = :mint_id")
values["mint_id"] = mint_id
if melt_id is not None:
clauses.append("melt_id = :melt_id")
values["melt_id"] = melt_id
await (conn or db).execute( # type: ignore
f"UPDATE proofs SET {', '.join(clauses)} WHERE secret = :secret",
{**values, "secret": str(proof.secret)},
)
async def secret_used(
secret: str,
db: Database,
conn: Optional[Connection] = None,
) -> bool:
rows = await (conn or db).fetchone(
"""
SELECT * from proofs
WHERE secret = :secret
""",
{"secret": secret},
)
return rows is not None
async def store_keyset(
keyset: WalletKeyset,
mint_url: str = "",
db: Optional[Database] = None,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute( # type: ignore
"""
INSERT INTO keysets
(id, mint_url, valid_from, valid_to, first_seen, active, public_keys, unit, input_fee_ppk)
VALUES (:id, :mint_url, :valid_from, :valid_to, :first_seen, :active, :public_keys, :unit, :input_fee_ppk)
""",
{
"id": keyset.id,
"mint_url": mint_url or keyset.mint_url,
"valid_from": keyset.valid_from or int(time.time()),
"valid_to": keyset.valid_to or int(time.time()),
"first_seen": keyset.first_seen or int(time.time()),
"active": keyset.active,
"public_keys": keyset.serialize(),
"unit": keyset.unit.name,
"input_fee_ppk": keyset.input_fee_ppk,
},
)
async def get_keysets(
id: str = "",
mint_url: Optional[str] = None,
unit: Optional[str] = None,
db: Optional[Database] = None,
conn: Optional[Connection] = None,
) -> List[WalletKeyset]:
clauses = []
values: Dict[str, Any] = {}
if id:
clauses.append("id = :id")
values["id"] = id
if mint_url:
clauses.append("mint_url = :mint_url")
values["mint_url"] = mint_url
if unit:
clauses.append("unit = :unit")
values["unit"] = unit
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
rows = await (conn or db).fetchall( # type: ignore
f"""
SELECT * from keysets
{where}
""",
values,
)
return [WalletKeyset.from_row(r) for r in rows] # type: ignore
async def update_keyset(
keyset: WalletKeyset,
db: Database,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
UPDATE keysets
SET active = :active
WHERE id = :id
""",
{
"active": keyset.active,
"id": keyset.id,
},
)
async def store_bolt11_mint_quote(
db: Database,
quote: MintQuote,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
INSERT INTO bolt11_mint_quotes
(quote, mint, method, request, checking_id, unit, amount, state, created_time, paid_time, expiry, privkey)
VALUES (:quote, :mint, :method, :request, :checking_id, :unit, :amount, :state, :created_time, :paid_time, :expiry, :privkey)
""",
{
"quote": quote.quote,
"mint": quote.mint,
"method": quote.method,
"request": quote.request,
"checking_id": quote.checking_id,
"unit": quote.unit,
"amount": quote.amount,
"state": quote.state.value,
"created_time": quote.created_time,
"paid_time": quote.paid_time,
"expiry": quote.expiry,
"privkey": quote.privkey or "",
},
)
async def get_bolt11_mint_quote(
db: Database,
quote: str | None = None,
request: str | None = None,
conn: Optional[Connection] = None,
) -> Optional[MintQuote]:
if not quote and not request:
raise ValueError("quote or request must be provided")
clauses = []
values: Dict[str, Any] = {}
if quote:
clauses.append("quote = :quote")
values["quote"] = quote
if request:
clauses.append("request = :request")
values["request"] = request
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
row = await (conn or db).fetchone(
f"""
SELECT * from bolt11_mint_quotes
{where}
""",
values,
)
return MintQuote.from_row(row) if row else None # type: ignore
async def get_bolt11_mint_quotes(
db: Database,
mint: Optional[str] = None,
state: Optional[MintQuoteState] = None,
conn: Optional[Connection] = None,
) -> List[MintQuote]:
clauses = []
values: Dict[str, Any] = {}
if mint:
clauses.append("mint = :mint")
values["mint"] = mint
if state:
clauses.append("state = :state")
values["state"] = state.value
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
rows = await (conn or db).fetchall(
f"""
SELECT * from bolt11_mint_quotes
{where}
""",
values,
)
return [MintQuote.from_row(r) for r in rows] # type: ignore
async def update_bolt11_mint_quote(
db: Database,
quote: str,
state: MintQuoteState,
paid_time: int,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
UPDATE bolt11_mint_quotes
SET state = :state, paid_time = :paid_time
WHERE quote = :quote
""",
{
"state": state.value,
"paid_time": paid_time,
"quote": quote,
},
)
async def store_bolt11_melt_quote(
db: Database,
quote: MeltQuote,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
INSERT INTO bolt11_melt_quotes
(quote, mint, method, request, checking_id, unit, amount, fee_reserve, state, created_time, paid_time, fee_paid, payment_preimage, expiry, change)
VALUES (:quote, :mint, :method, :request, :checking_id, :unit, :amount, :fee_reserve, :state, :created_time, :paid_time, :fee_paid, :payment_preimage, :expiry, :change)
""",
{
"quote": quote.quote,
"mint": quote.mint,
"method": quote.method,
"request": quote.request,
"checking_id": quote.checking_id,
"unit": quote.unit,
"amount": quote.amount,
"fee_reserve": quote.fee_reserve,
"state": quote.state.value,
"created_time": quote.created_time,
"paid_time": quote.paid_time,
"fee_paid": quote.fee_paid,
"payment_preimage": quote.payment_preimage,
"expiry": quote.expiry,
"change": (
json.dumps([c.dict() for c in quote.change]) if quote.change else ""
),
},
)
async def get_bolt11_melt_quote(
db: Database,
quote: Optional[str] = None,
request: Optional[str] = None,
conn: Optional[Connection] = None,
) -> Optional[MeltQuote]:
if not quote and not request:
raise ValueError("quote or request must be provided")
clauses = []
values: Dict[str, Any] = {}
if quote:
clauses.append("quote = :quote")
values["quote"] = quote
if request:
clauses.append("request = :request")
values["request"] = request
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
row = await (conn or db).fetchone(
f"""
SELECT * from bolt11_melt_quotes
{where}
""",
values,
)
return MeltQuote.from_row(row) if row else None # type: ignore
async def get_bolt11_melt_quotes(
db: Database,
mint: Optional[str] = None,
state: Optional[MeltQuoteState] = None,
conn: Optional[Connection] = None,
) -> List[MeltQuote]:
clauses = []
values: Dict[str, Any] = {}
if mint:
clauses.append("mint = :mint")
values["mint"] = mint
if state:
clauses.append("state = :state")
values["state"] = state.value
where = ""
if clauses:
where = f"WHERE {' AND '.join(clauses)}"
rows = await (conn or db).fetchall(
f"""
SELECT * from bolt11_melt_quotes
{where}
""",
values,
)
return [MeltQuote.from_row(r) for r in rows] # type: ignore
async def update_bolt11_melt_quote(
db: Database,
quote: str,
state: MeltQuoteState,
paid_time: int,
fee_paid: int,
payment_preimage: str,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
UPDATE bolt11_melt_quotes
SET state = :state, paid_time = :paid_time, fee_paid = :fee_paid, payment_preimage = :payment_preimage
WHERE quote = :quote
""",
{
"state": state.value,
"paid_time": paid_time,
"fee_paid": fee_paid,
"payment_preimage": payment_preimage,
"quote": quote,
},
)
async def bump_secret_derivation(
db: Database,
keyset_id: str,
by: int = 1,
skip: bool = False,
conn: Optional[Connection] = None,
) -> int:
rows = await (conn or db).fetchone(
"SELECT counter from keysets WHERE id = :keyset_id", {"keyset_id": keyset_id}
)
# if no counter for this keyset, create one
if not rows:
await (conn or db).execute(
"UPDATE keysets SET counter = :counter WHERE id = :keyset_id",
{
"counter": 0,
"keyset_id": keyset_id,
},
)
counter = 0
else:
counter = int(rows["counter"])
if not skip:
await (conn or db).execute(
"UPDATE keysets SET counter = counter + :by WHERE id = :keyset_id",
{"by": by, "keyset_id": keyset_id},
)
return counter
async def set_secret_derivation(
db: Database,
keyset_id: str,
counter: int,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"UPDATE keysets SET counter = :counter WHERE id = :keyset_id",
{
"counter": counter,
"keyset_id": keyset_id,
},
)
async def set_nostr_last_check_timestamp(
db: Database,
timestamp: int,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"UPDATE nostr SET last = :last WHERE type = :type",
{"last": timestamp, "type": "dm"},
)
async def get_nostr_last_check_timestamp(
db: Database,
conn: Optional[Connection] = None,
) -> Optional[int]:
row = await (conn or db).fetchone(
"""
SELECT last from nostr WHERE type = :type
""",
{"type": "dm"},
)
return row[0] if row else None # type: ignore
async def get_seed_and_mnemonic(
db: Database,
conn: Optional[Connection] = None,
) -> Optional[Tuple[str, str]]:
row = await (conn or db).fetchone(
"""
SELECT seed, mnemonic from seed
"""
)
return (
(
row["seed"],
row["mnemonic"],
)
if row
else None
)
async def store_seed_and_mnemonic(
db: Database,
seed: str,
mnemonic: str,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
INSERT INTO seed
(seed, mnemonic)
VALUES (:seed, :mnemonic)
""",
{
"seed": seed,
"mnemonic": mnemonic,
},
)
async def store_mint(
db: Database,
mint: WalletMint,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
INSERT INTO mints
(url, info, updated)
VALUES (:url, :info, :updated)
""",
{
"url": mint.url,
"info": mint.info,
"updated": int(time.time()),
},
)
async def update_mint(
db: Database,
mint: WalletMint,
conn: Optional[Connection] = None,
) -> None:
await (conn or db).execute(
"""
UPDATE mints
SET info = :info, updated = :updated, access_token = :access_token, refresh_token = :refresh_token, username = :username, password = :password
WHERE url = :url
""",
{
"url": mint.url,
"info": mint.info,
"updated": int(time.time()),
"access_token": mint.access_token,
"refresh_token": mint.refresh_token,
"username": mint.username,
"password": mint.password,
},
)
async def get_mint_by_url(
db: Database,
url: str,
conn: Optional[Connection] = None,
) -> Optional[WalletMint]:
row = await (conn or db).fetchone(
"""
SELECT * from mints WHERE url = :url
""",
{"url": url},
)
return WalletMint.parse_obj(dict(row)) if row else None