parent
bd07f7a5ef
commit
b54eedee84
31 changed files with 2078 additions and 163 deletions
|
|
@ -39,7 +39,6 @@ async def get_standalone_payment(
|
|||
) -> Payment | None:
|
||||
clause: str = "checking_id = :checking_id OR payment_hash = :hash"
|
||||
values = {
|
||||
"wallet_id": wallet_id,
|
||||
"checking_id": checking_id_or_hash,
|
||||
"hash": checking_id_or_hash,
|
||||
}
|
||||
|
|
@ -47,6 +46,10 @@ async def get_standalone_payment(
|
|||
clause = f"({clause}) AND amount > 0"
|
||||
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return None
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
clause = f"({clause}) AND wallet_id = :wallet_id"
|
||||
|
||||
row = await (conn or db).fetchone(
|
||||
|
|
@ -66,13 +69,16 @@ async def get_standalone_payment(
|
|||
async def get_wallet_payment(
|
||||
wallet_id: str, payment_hash: str, conn: Connection | None = None
|
||||
) -> Payment | None:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return None
|
||||
payment = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *
|
||||
FROM apipayments
|
||||
WHERE wallet_id = :wallet AND payment_hash = :hash
|
||||
""",
|
||||
{"wallet": wallet_id, "hash": payment_hash},
|
||||
{"wallet": wallet.source_wallet_id, "hash": payment_hash},
|
||||
Payment,
|
||||
)
|
||||
return payment
|
||||
|
|
@ -128,7 +134,11 @@ async def get_payments_paginated( # noqa: C901
|
|||
clause.append(f"time > {db.timestamp_placeholder('time')}")
|
||||
|
||||
if wallet_id:
|
||||
values["wallet_id"] = wallet_id
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return Page(data=[], total=0)
|
||||
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
clause.append("wallet_id = :wallet_id")
|
||||
elif user_id:
|
||||
only_user_wallets = await _only_user_wallets_statement(user_id, conn=conn)
|
||||
|
|
@ -320,7 +330,7 @@ async def get_payments_history(
|
|||
|
||||
date_trunc = db.datetime_grouping(group)
|
||||
|
||||
values = {
|
||||
values: dict[str, Any] = {
|
||||
"wallet_id": wallet_id,
|
||||
}
|
||||
# count outgoing payments if they are still pending
|
||||
|
|
@ -350,10 +360,10 @@ async def get_payments_history(
|
|||
)
|
||||
if wallet_id:
|
||||
wallet = await get_wallet(wallet_id)
|
||||
if wallet:
|
||||
if not wallet or not wallet.can_view_payments:
|
||||
return []
|
||||
balance = wallet.balance_msat
|
||||
else:
|
||||
raise ValueError("Unknown wallet")
|
||||
values["wallet_id"] = wallet.source_wallet_id
|
||||
else:
|
||||
balance = await get_total_balance()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from time import time
|
|||
from uuid import uuid4
|
||||
|
||||
from lnbits.core.db import db
|
||||
from lnbits.core.models.wallets import WalletsFilters
|
||||
from lnbits.core.models.wallets import WalletsFilters, WalletType
|
||||
from lnbits.db import Connection, Filters, Page
|
||||
from lnbits.settings import settings
|
||||
|
||||
|
|
@ -14,17 +14,22 @@ async def create_wallet(
|
|||
*,
|
||||
user_id: str,
|
||||
wallet_name: str | None = None,
|
||||
wallet_type: WalletType = WalletType.LIGHTNING,
|
||||
shared_wallet_id: str | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
wallet_id = uuid4().hex
|
||||
wallet = Wallet(
|
||||
id=wallet_id,
|
||||
name=wallet_name or settings.lnbits_default_wallet_name,
|
||||
wallet_type=wallet_type.value,
|
||||
shared_wallet_id=shared_wallet_id,
|
||||
user=user_id,
|
||||
adminkey=uuid4().hex,
|
||||
inkey=uuid4().hex,
|
||||
currency=settings.lnbits_default_accounting_currency or "USD",
|
||||
)
|
||||
|
||||
await (conn or db).insert("wallets", wallet)
|
||||
return wallet
|
||||
|
||||
|
|
@ -103,7 +108,7 @@ async def delete_unused_wallets(
|
|||
)
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
async def get_standalone_wallet(
|
||||
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
query = """
|
||||
|
|
@ -121,8 +126,23 @@ async def get_wallet(
|
|||
)
|
||||
|
||||
|
||||
async def get_wallet(
|
||||
wallet_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
wallet = await get_standalone_wallet(wallet_id, deleted, conn)
|
||||
if not wallet:
|
||||
return None
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
return await get_source_wallet(wallet, conn)
|
||||
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_wallets(
|
||||
user_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
user_id: str,
|
||||
deleted: bool | None = False,
|
||||
wallet_type: WalletType | None = None,
|
||||
conn: Connection | None = None,
|
||||
) -> list[Wallet]:
|
||||
query = """
|
||||
SELECT *, COALESCE((
|
||||
|
|
@ -132,12 +152,20 @@ async def get_wallets(
|
|||
"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
return await (conn or db).fetchall(
|
||||
if wallet_type is not None:
|
||||
query += " AND wallet_type = :wallet_type "
|
||||
wallets = await (conn or db).fetchall(
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
{
|
||||
"user": user_id,
|
||||
"deleted": deleted,
|
||||
"wallet_type": wallet_type.value if wallet_type else None,
|
||||
},
|
||||
Wallet,
|
||||
)
|
||||
|
||||
return await get_source_wallets(wallets, conn)
|
||||
|
||||
|
||||
async def get_wallets_paginated(
|
||||
user_id: str,
|
||||
|
|
@ -149,7 +177,7 @@ async def get_wallets_paginated(
|
|||
deleted = False
|
||||
|
||||
where: list[str] = [""" "user" = :user AND deleted = :deleted """]
|
||||
return await (conn or db).fetch_page(
|
||||
wallets = await (conn or db).fetch_page(
|
||||
"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
|
|
@ -161,18 +189,24 @@ async def get_wallets_paginated(
|
|||
model=Wallet,
|
||||
)
|
||||
|
||||
wallets.data = await get_source_wallets(wallets.data, conn)
|
||||
return wallets
|
||||
|
||||
|
||||
async def get_wallets_ids(
|
||||
user_id: str, deleted: bool | None = False, conn: Connection | None = None
|
||||
) -> list[str]:
|
||||
query = """SELECT id FROM wallets WHERE "user" = :user"""
|
||||
query = """SELECT * FROM wallets WHERE "user" = :user"""
|
||||
if deleted is not None:
|
||||
query += " AND deleted = :deleted "
|
||||
result: list[dict] = await (conn or db).fetchall(
|
||||
wallets = await (conn or db).fetchall(
|
||||
query,
|
||||
{"user": user_id, "deleted": deleted},
|
||||
Wallet,
|
||||
)
|
||||
return [row["id"] for row in result]
|
||||
|
||||
wallets = await get_source_wallets(wallets, conn)
|
||||
return [w.source_wallet_id for w in wallets if w.can_view_payments]
|
||||
|
||||
|
||||
async def get_wallets_count():
|
||||
|
|
@ -185,7 +219,7 @@ async def get_wallet_for_key(
|
|||
key: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet | None:
|
||||
return await (conn or db).fetchone(
|
||||
wallet = await (conn or db).fetchone(
|
||||
"""
|
||||
SELECT *, COALESCE((
|
||||
SELECT balance FROM balances WHERE wallet_id = wallets.id
|
||||
|
|
@ -196,6 +230,39 @@ async def get_wallet_for_key(
|
|||
{"key": key},
|
||||
Wallet,
|
||||
)
|
||||
if not wallet:
|
||||
return None
|
||||
|
||||
if wallet.is_lightning_shared_wallet:
|
||||
mw = await get_source_wallet(wallet, conn)
|
||||
return mw
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_source_wallet(
|
||||
wallet: Wallet, conn: Connection | None = None
|
||||
) -> Wallet | None:
|
||||
if not wallet.is_lightning_shared_wallet:
|
||||
return wallet
|
||||
if not wallet.shared_wallet_id:
|
||||
return None
|
||||
|
||||
shared_wallet = await get_standalone_wallet(wallet.shared_wallet_id, False, conn)
|
||||
if not shared_wallet:
|
||||
return None
|
||||
wallet.mirror_shared_wallet(shared_wallet)
|
||||
return wallet
|
||||
|
||||
|
||||
async def get_source_wallets(
|
||||
wallet: list[Wallet], conn: Connection | None = None
|
||||
) -> list[Wallet]:
|
||||
source_wallets = []
|
||||
for w in wallet:
|
||||
source_wallet = await get_source_wallet(w, conn)
|
||||
if source_wallet:
|
||||
source_wallets.append(source_wallet)
|
||||
return source_wallets
|
||||
|
||||
|
||||
async def get_total_balance(conn: Connection | None = None):
|
||||
|
|
|
|||
|
|
@ -743,3 +743,19 @@ async def m034_add_stored_paylinks_to_wallet(db: Connection):
|
|||
ALTER TABLE wallets ADD COLUMN stored_paylinks TEXT
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m035_add_wallet_type_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN wallet_type TEXT DEFAULT 'lightning'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
async def m036_add_shared_wallet_column(db: Connection):
|
||||
await db.execute(
|
||||
"""
|
||||
ALTER TABLE wallets ADD COLUMN shared_wallet_id TEXT
|
||||
"""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ class UserNotifications(BaseModel):
|
|||
incoming_payments_sats: int = 0
|
||||
|
||||
|
||||
class WalletInviteRequest(BaseModel):
|
||||
request_id: str
|
||||
from_user_name: str | None = None
|
||||
to_wallet_id: str
|
||||
to_wallet_name: str
|
||||
|
||||
|
||||
class UserExtra(BaseModel):
|
||||
email_verified: bool | None = False
|
||||
first_name: str | None = None
|
||||
|
|
@ -46,6 +53,41 @@ class UserExtra(BaseModel):
|
|||
|
||||
notifications: UserNotifications = UserNotifications()
|
||||
|
||||
wallet_invite_requests: list[WalletInviteRequest] = []
|
||||
|
||||
def add_wallet_invite_request(
|
||||
self,
|
||||
request_id: str,
|
||||
to_wallet_id: str,
|
||||
to_wallet_name: str,
|
||||
from_user_name: str | None = None,
|
||||
) -> WalletInviteRequest:
|
||||
self.remove_wallet_invite_request(request_id)
|
||||
invite = WalletInviteRequest(
|
||||
request_id=request_id,
|
||||
from_user_name=from_user_name,
|
||||
to_wallet_id=to_wallet_id,
|
||||
to_wallet_name=to_wallet_name,
|
||||
)
|
||||
self.wallet_invite_requests.append(invite)
|
||||
return invite
|
||||
|
||||
def find_wallet_invite_request(self, request_id: str) -> WalletInviteRequest | None:
|
||||
for invite in self.wallet_invite_requests:
|
||||
if invite.request_id == request_id:
|
||||
return invite
|
||||
return None
|
||||
|
||||
def remove_wallet_invite_request(
|
||||
self,
|
||||
request_id: str,
|
||||
):
|
||||
self.wallet_invite_requests = [
|
||||
invite
|
||||
for invite in self.wallet_invite_requests
|
||||
if invite.request_id != request_id
|
||||
]
|
||||
|
||||
|
||||
class EndpointAccess(BaseModel):
|
||||
path: str
|
||||
|
|
|
|||
|
|
@ -21,10 +21,95 @@ class BaseWallet(BaseModel):
|
|||
balance_msat: int
|
||||
|
||||
|
||||
class WalletType(Enum):
|
||||
LIGHTNING = "lightning"
|
||||
LIGHTNING_SHARED = "lightning-shared"
|
||||
|
||||
|
||||
class WalletPermission(Enum):
|
||||
VIEW_PAYMENTS = "view-payments"
|
||||
RECEIVE_PAYMENTS = "receive-payments"
|
||||
SEND_PAYMENTS = "send-payments"
|
||||
|
||||
def __str__(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class WalletShareStatus(Enum):
|
||||
INVITE_SENT = "invite_sent"
|
||||
APPROVED = "approved"
|
||||
|
||||
|
||||
class WalletSharePermission(BaseModel):
|
||||
# unique identifier for this share request
|
||||
request_id: str | None = None
|
||||
# username of the invited user
|
||||
username: str
|
||||
# ID of the wallet being shared with
|
||||
shared_with_wallet_id: str | None = None
|
||||
# permissions being granted
|
||||
permissions: list[WalletPermission] = []
|
||||
# status of the share request
|
||||
status: WalletShareStatus
|
||||
comment: str | None = None
|
||||
|
||||
def approve(
|
||||
self,
|
||||
permissions: list[WalletPermission] | None = None,
|
||||
shared_with_wallet_id: str | None = None,
|
||||
):
|
||||
self.status = WalletShareStatus.APPROVED
|
||||
if permissions is not None:
|
||||
self.permissions = permissions
|
||||
if shared_with_wallet_id is not None:
|
||||
self.shared_with_wallet_id = shared_with_wallet_id
|
||||
|
||||
@property
|
||||
def is_approved(self) -> bool:
|
||||
return self.status == WalletShareStatus.APPROVED
|
||||
|
||||
|
||||
class WalletExtra(BaseModel):
|
||||
icon: str = "flash_on"
|
||||
color: str = "primary"
|
||||
pinned: bool = False
|
||||
# What permissions this wallet grants when it's shared with other users
|
||||
shared_with: list[WalletSharePermission] = []
|
||||
|
||||
def invite_user_to_shared_wallet(
|
||||
self,
|
||||
request_id: str,
|
||||
request_type: WalletShareStatus,
|
||||
username: str,
|
||||
permissions: list[WalletPermission] | None = None,
|
||||
) -> WalletSharePermission:
|
||||
share = WalletSharePermission(
|
||||
request_id=request_id,
|
||||
username=username,
|
||||
status=request_type,
|
||||
permissions=permissions or [],
|
||||
)
|
||||
self.shared_with.append(share)
|
||||
return share
|
||||
|
||||
def find_share_by_id(self, request_id: str) -> WalletSharePermission | None:
|
||||
for share in self.shared_with:
|
||||
if share.request_id == request_id:
|
||||
return share
|
||||
return None
|
||||
|
||||
def find_share_for_wallet(
|
||||
self, shared_with_wallet_id: str
|
||||
) -> WalletSharePermission | None:
|
||||
for share in self.shared_with:
|
||||
if share.shared_with_wallet_id == shared_with_wallet_id:
|
||||
return share
|
||||
return None
|
||||
|
||||
def remove_share_by_id(self, request_id: str):
|
||||
self.shared_with = [
|
||||
share for share in self.shared_with if share.request_id != request_id
|
||||
]
|
||||
|
||||
|
||||
class Wallet(BaseModel):
|
||||
|
|
@ -33,6 +118,9 @@ class Wallet(BaseModel):
|
|||
name: str
|
||||
adminkey: str
|
||||
inkey: str
|
||||
wallet_type: str = WalletType.LIGHTNING.value
|
||||
# Must be set only for shared wallets
|
||||
shared_wallet_id: str | None = None
|
||||
deleted: bool = False
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
|
@ -40,6 +128,65 @@ class Wallet(BaseModel):
|
|||
balance_msat: int = Field(default=0, no_database=True)
|
||||
extra: WalletExtra = WalletExtra()
|
||||
stored_paylinks: StoredPayLinks = StoredPayLinks()
|
||||
# What permission this wallet has when it's a shared wallet
|
||||
share_permissions: list[WalletPermission] = Field(default=[], no_database=True)
|
||||
|
||||
def __init__(self, **data):
|
||||
super().__init__(**data)
|
||||
self._validate_data()
|
||||
|
||||
def mirror_shared_wallet(
|
||||
self,
|
||||
shared_wallet: Wallet,
|
||||
):
|
||||
if not shared_wallet.is_lightning_wallet:
|
||||
return None
|
||||
|
||||
self.wallet_type = WalletType.LIGHTNING_SHARED.value
|
||||
self.shared_wallet_id = shared_wallet.id
|
||||
self.name = shared_wallet.name
|
||||
self.share_permissions = shared_wallet.get_share_permissions(self.id)
|
||||
|
||||
if len(self.share_permissions):
|
||||
self.currency = shared_wallet.currency
|
||||
self.balance_msat = shared_wallet.balance_msat
|
||||
|
||||
self.stored_paylinks = shared_wallet.stored_paylinks
|
||||
self.extra.icon = shared_wallet.extra.icon
|
||||
self.extra.color = shared_wallet.extra.color
|
||||
|
||||
def get_share_permissions(self, wallet_id: str) -> list[WalletPermission]:
|
||||
for share in self.extra.shared_with:
|
||||
if share.shared_with_wallet_id == wallet_id and share.is_approved:
|
||||
return share.permissions
|
||||
return []
|
||||
|
||||
def has_permission(self, permission: WalletPermission) -> bool:
|
||||
if self.is_lightning_wallet:
|
||||
return True
|
||||
if self.is_lightning_shared_wallet:
|
||||
return permission in self.share_permissions
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def source_wallet_id(self) -> str:
|
||||
"""For shared wallets return the original wallet ID, else return own ID."""
|
||||
if self.is_lightning_shared_wallet and len(self.share_permissions):
|
||||
return self.shared_wallet_id or self.id
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def can_receive_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.RECEIVE_PAYMENTS)
|
||||
|
||||
@property
|
||||
def can_send_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.SEND_PAYMENTS)
|
||||
|
||||
@property
|
||||
def can_view_payments(self) -> bool:
|
||||
return self.has_permission(WalletPermission.VIEW_PAYMENTS)
|
||||
|
||||
@property
|
||||
def balance(self) -> int:
|
||||
|
|
@ -57,9 +204,24 @@ class Wallet(BaseModel):
|
|||
except Exception:
|
||||
return ""
|
||||
|
||||
@property
|
||||
def is_lightning_wallet(self) -> bool:
|
||||
return self.wallet_type == WalletType.LIGHTNING.value
|
||||
|
||||
@property
|
||||
def is_lightning_shared_wallet(self) -> bool:
|
||||
return self.wallet_type == WalletType.LIGHTNING_SHARED.value
|
||||
|
||||
def _validate_data(self):
|
||||
if self.is_lightning_shared_wallet:
|
||||
if not self.shared_wallet_id:
|
||||
raise ValueError("Shared wallet ID must be set for shared wallets.")
|
||||
|
||||
|
||||
class CreateWallet(BaseModel):
|
||||
name: str | None = None
|
||||
wallet_type: WalletType = WalletType.LIGHTNING
|
||||
shared_wallet_id: str | None = None
|
||||
|
||||
|
||||
class KeyType(Enum):
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from lnbits.core.crud import (
|
|||
mark_webhook_sent,
|
||||
)
|
||||
from lnbits.core.crud.users import get_user
|
||||
from lnbits.core.crud.wallets import get_wallet
|
||||
from lnbits.core.models import Payment, Wallet
|
||||
from lnbits.core.models.notifications import (
|
||||
NOTIFICATION_TEMPLATES,
|
||||
|
|
@ -257,6 +258,12 @@ async def dispatch_webhook(payment: Payment):
|
|||
async def send_payment_notification(wallet: Wallet, payment: Payment):
|
||||
try:
|
||||
await send_ws_payment_notification(wallet, payment)
|
||||
for shared in wallet.extra.shared_with:
|
||||
if not shared.shared_with_wallet_id:
|
||||
continue
|
||||
shared_wallet = await get_wallet(shared.shared_with_wallet_id)
|
||||
if shared_wallet and shared_wallet.can_view_payments:
|
||||
await send_ws_payment_notification(shared_wallet, payment)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending websocket payment notification {e!s}")
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -72,6 +72,11 @@ async def pay_invoice(
|
|||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
amount_msat = invoice.amount_msat
|
||||
wallet = await _check_wallet_for_payment(wallet_id, tag, amount_msat, new_conn)
|
||||
if not wallet.can_send_payments:
|
||||
raise PaymentError(
|
||||
"Wallet does not have permission to pay invoices.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
if await is_internal_status_success(invoice.payment_hash, new_conn):
|
||||
raise PaymentError("Internal invoice already paid.", status="failed")
|
||||
|
|
@ -79,7 +84,7 @@ async def pay_invoice(
|
|||
_, extra = await calculate_fiat_amounts(amount_msat / 1000, wallet, extra=extra)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
bolt11=payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
amount_msat=-amount_msat,
|
||||
|
|
@ -88,7 +93,7 @@ async def pay_invoice(
|
|||
extra=extra,
|
||||
)
|
||||
|
||||
payment = await _pay_invoice(wallet.id, create_payment_model, conn)
|
||||
payment = await _pay_invoice(wallet.source_wallet_id, create_payment_model, conn)
|
||||
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as new_conn:
|
||||
await _credit_service_fee_wallet(wallet, payment, new_conn)
|
||||
|
|
@ -250,6 +255,12 @@ async def create_invoice(
|
|||
if not user_wallet:
|
||||
raise InvoiceError(f"Could not fetch wallet '{wallet_id}'.", status="failed")
|
||||
|
||||
if not user_wallet.can_receive_payments:
|
||||
raise InvoiceError(
|
||||
"Wallet does not have permission to create invoices.",
|
||||
status="failed",
|
||||
)
|
||||
|
||||
invoice_memo = None if description_hash else memo[:640]
|
||||
|
||||
# use the fake wallet if the invoice is for internal use only
|
||||
|
|
@ -308,7 +319,7 @@ async def create_invoice(
|
|||
invoice = bolt11_decode(invoice_response.payment_request)
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=wallet_id,
|
||||
wallet_id=user_wallet.source_wallet_id,
|
||||
bolt11=invoice_response.payment_request,
|
||||
payment_hash=invoice.payment_hash,
|
||||
preimage=invoice_response.preimage,
|
||||
|
|
@ -456,7 +467,7 @@ async def update_wallet_balance(
|
|||
await create_payment(
|
||||
checking_id=f"internal_{payment_hash}",
|
||||
data=CreatePayment(
|
||||
wallet_id=wallet.id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
bolt11=bolt11,
|
||||
payment_hash=payment_hash,
|
||||
amount_msat=amount * 1000,
|
||||
|
|
@ -475,7 +486,7 @@ async def update_wallet_balance(
|
|||
raise ValueError("Balance change failed, amount exceeds maximum balance.")
|
||||
async with db.reuse_conn(conn) if conn else db.connect() as conn:
|
||||
payment = await create_invoice(
|
||||
wallet_id=wallet.id,
|
||||
wallet_id=wallet.source_wallet_id,
|
||||
amount=amount,
|
||||
memo="Admin credit",
|
||||
internal=True,
|
||||
|
|
@ -910,7 +921,7 @@ async def _credit_service_fee_wallet(
|
|||
|
||||
memo = f"""
|
||||
Service fee for payment of {abs(payment.sat)} sats.
|
||||
Wallet: '{wallet.name}' ({wallet.id})."""
|
||||
Wallet: '{wallet.name}' ({wallet.source_wallet_id})."""
|
||||
|
||||
create_payment_model = CreatePayment(
|
||||
wallet_id=settings.lnbits_service_fee_wallet,
|
||||
|
|
|
|||
202
lnbits/core/services/wallets.py
Normal file
202
lnbits/core/services/wallets.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
from lnbits.core.crud.users import (
|
||||
get_account,
|
||||
get_account_by_username_or_email,
|
||||
update_account,
|
||||
)
|
||||
from lnbits.core.crud.wallets import (
|
||||
create_wallet,
|
||||
force_delete_wallet,
|
||||
get_standalone_wallet,
|
||||
get_wallet,
|
||||
get_wallets,
|
||||
update_wallet,
|
||||
)
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.users import Account
|
||||
from lnbits.core.models.wallets import (
|
||||
Wallet,
|
||||
WalletSharePermission,
|
||||
WalletShareStatus,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.db import Connection
|
||||
from lnbits.helpers import sha256s
|
||||
|
||||
|
||||
async def invite_to_wallet(
|
||||
source_wallet: Wallet, data: WalletSharePermission
|
||||
) -> WalletSharePermission:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Only lightning wallets can be shared.")
|
||||
if not data.username:
|
||||
raise ValueError("Username or email missing.")
|
||||
invited_user = await get_account_by_username_or_email(data.username)
|
||||
if not invited_user:
|
||||
raise ValueError("Invited user not found.")
|
||||
|
||||
request_id = sha256s(invited_user.id + source_wallet.id)
|
||||
share = source_wallet.extra.find_share_by_id(request_id)
|
||||
if share:
|
||||
raise ValueError("User already invited to this wallet.")
|
||||
|
||||
invite_request = source_wallet.extra.invite_user_to_shared_wallet(
|
||||
request_id=request_id,
|
||||
request_type=WalletShareStatus.INVITE_SENT,
|
||||
username=data.username,
|
||||
permissions=data.permissions,
|
||||
)
|
||||
await update_wallet(source_wallet)
|
||||
|
||||
wallet_owner = await get_account(source_wallet.user)
|
||||
if not wallet_owner:
|
||||
raise ValueError("Cannot find wallet owner.")
|
||||
invited_user.extra.add_wallet_invite_request(
|
||||
request_id=request_id,
|
||||
from_user_name=wallet_owner.username or wallet_owner.email,
|
||||
to_wallet_id=source_wallet.id,
|
||||
to_wallet_name=source_wallet.name,
|
||||
)
|
||||
await update_account(invited_user)
|
||||
|
||||
return invite_request
|
||||
|
||||
|
||||
async def reject_wallet_invitation(invited_user_id: str, share_request_id: str):
|
||||
invited_user = await get_account(invited_user_id)
|
||||
if not invited_user:
|
||||
raise ValueError("Invited user not found.")
|
||||
|
||||
existing_request = invited_user.extra.find_wallet_invite_request(share_request_id)
|
||||
if not existing_request:
|
||||
raise ValueError("Invitation not found.")
|
||||
|
||||
invited_user.extra.remove_wallet_invite_request(share_request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
|
||||
async def update_wallet_share_permissions(
|
||||
source_wallet: Wallet, data: WalletSharePermission
|
||||
) -> WalletSharePermission:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Only lightning wallets can be shared.")
|
||||
if not data.shared_with_wallet_id:
|
||||
raise ValueError("Wallet ID missing.")
|
||||
|
||||
share = source_wallet.extra.find_share_for_wallet(data.shared_with_wallet_id)
|
||||
if not share:
|
||||
raise ValueError("Share not found")
|
||||
|
||||
if not share.shared_with_wallet_id:
|
||||
raise ValueError("Share does not have a mirror wallet ID.")
|
||||
|
||||
mirror_wallet = await get_wallet(share.shared_with_wallet_id)
|
||||
if not mirror_wallet:
|
||||
raise ValueError("Target wallet not found")
|
||||
if not mirror_wallet.is_lightning_shared_wallet:
|
||||
raise ValueError("Target wallet is not a shared wallet.")
|
||||
if mirror_wallet.shared_wallet_id != source_wallet.id:
|
||||
raise ValueError("Not the owner of the shared wallet.")
|
||||
|
||||
share.approve(permissions=data.permissions)
|
||||
await update_wallet(source_wallet)
|
||||
return share
|
||||
|
||||
|
||||
async def delete_wallet_share(source_wallet: Wallet, request_id: str) -> SimpleStatus:
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Source wallet is not a lightning wallet.")
|
||||
|
||||
share = source_wallet.extra.find_share_by_id(request_id)
|
||||
if not share:
|
||||
raise ValueError("Wallet share not found.")
|
||||
source_wallet.extra.remove_share_by_id(request_id)
|
||||
|
||||
invited_user = await get_account_by_username_or_email(share.username)
|
||||
if not invited_user:
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(
|
||||
success=True, message="Permission removed. Invited user not found."
|
||||
)
|
||||
if invited_user.extra.find_wallet_invite_request(request_id):
|
||||
invited_user.extra.remove_wallet_invite_request(request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
mirror_wallets = await get_wallets(
|
||||
invited_user.id, wallet_type=WalletType.LIGHTNING_SHARED
|
||||
)
|
||||
mirror_wallet = next(
|
||||
(w for w in mirror_wallets if w.shared_wallet_id == source_wallet.id), None
|
||||
)
|
||||
|
||||
if not mirror_wallet:
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(
|
||||
success=True, message="Permission removed. Target wallet not found."
|
||||
)
|
||||
|
||||
if not mirror_wallet.is_lightning_shared_wallet:
|
||||
raise ValueError("Target wallet is not a shared lightning wallet.")
|
||||
|
||||
if mirror_wallet.shared_wallet_id != source_wallet.id:
|
||||
raise ValueError("Not the owner of the shared wallet.")
|
||||
|
||||
await force_delete_wallet(mirror_wallet.id)
|
||||
|
||||
await update_wallet(source_wallet)
|
||||
return SimpleStatus(success=True, message="Permission removed.")
|
||||
|
||||
|
||||
async def create_lightning_shared_wallet(
|
||||
user_id: str,
|
||||
source_wallet_id: str,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
source_wallet = await get_standalone_wallet(source_wallet_id, conn=conn)
|
||||
if not source_wallet:
|
||||
raise ValueError("Shared wallet does not exist.")
|
||||
|
||||
if not source_wallet.is_lightning_wallet:
|
||||
raise ValueError("Shared wallet is not a lightning wallet.")
|
||||
|
||||
if source_wallet.user == user_id:
|
||||
raise ValueError("Cannot mirror your own wallet.")
|
||||
|
||||
invited_user = await get_account(user_id, conn=conn)
|
||||
if not invited_user:
|
||||
raise ValueError("Cannot find invited user.")
|
||||
|
||||
return await _accept_invitation_to_shared_wallet(
|
||||
invited_user, source_wallet, conn=conn
|
||||
)
|
||||
|
||||
|
||||
async def _accept_invitation_to_shared_wallet(
|
||||
invited_user: Account,
|
||||
source_wallet: Wallet,
|
||||
conn: Connection | None = None,
|
||||
) -> Wallet:
|
||||
request_id = sha256s(invited_user.id + source_wallet.id)
|
||||
existing_request = source_wallet.extra.find_share_by_id(request_id)
|
||||
if not existing_request:
|
||||
raise ValueError("No invitation found for this invited user.")
|
||||
if existing_request.status == WalletShareStatus.APPROVED:
|
||||
raise ValueError("This wallet is already shared with you.")
|
||||
if existing_request.status != WalletShareStatus.INVITE_SENT:
|
||||
raise ValueError("Unknown request type.")
|
||||
|
||||
invited_user.extra.remove_wallet_invite_request(request_id)
|
||||
await update_account(invited_user)
|
||||
|
||||
# todo: double check if user already has a mirror wallet for this source wallet
|
||||
|
||||
mirror_wallet = await create_wallet(
|
||||
user_id=invited_user.id,
|
||||
wallet_name=source_wallet.name,
|
||||
wallet_type=WalletType.LIGHTNING_SHARED,
|
||||
shared_wallet_id=source_wallet.id,
|
||||
conn=conn,
|
||||
)
|
||||
existing_request.approve(shared_with_wallet_id=mirror_wallet.id)
|
||||
await update_wallet(source_wallet, conn=conn)
|
||||
mirror_wallet.mirror_shared_wallet(source_wallet)
|
||||
return mirror_wallet
|
||||
251
lnbits/core/templates/core/_wallet_share.html
Normal file
251
lnbits/core/templates/core/_wallet_share.html
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
<q-expansion-item
|
||||
v-if="wallet.walletType == 'lightning'"
|
||||
group="extras"
|
||||
icon="share"
|
||||
:label="$t('share_wallet')"
|
||||
>
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-avatar icon="share" style="margin-left: -5px" />
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<span v-text="$t('share_wallet')"></span>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side v-if="walletPendingRequests.length">
|
||||
<div class="row items-center">
|
||||
<q-icon name="hail" color="secondary" size="24px" />
|
||||
<span v-text="walletPendingRequests.length"></span>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
You can invite other users to have access to this wallet.
|
||||
<br />
|
||||
The access is limitted by the permission you grant.
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<q-input
|
||||
v-model="walletShareInvite.username"
|
||||
@keyup.enter="inviteUserToWallet()"
|
||||
label="Username"
|
||||
hint="Invite user to this wallet"
|
||||
dense
|
||||
>
|
||||
</q-input>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
:options="permissionOptions"
|
||||
v-model="walletShareInvite.permissions"
|
||||
emit-value
|
||||
map-options
|
||||
multiple
|
||||
use-chips
|
||||
dense
|
||||
class="q-pl-md"
|
||||
hint="Select permissions for this user"
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-1">
|
||||
<q-btn
|
||||
@click="inviteUserToWallet()"
|
||||
dense
|
||||
flat
|
||||
icon="person_add_alt"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-separator class="q-mt-lg"></q-separator>
|
||||
<q-expansion-item
|
||||
group="wallet_shares"
|
||||
dense
|
||||
expand-separator
|
||||
icon="share"
|
||||
:label="'Shared With (' + walletApprovedShares.length + ')'"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section v-if="walletApprovedShares.length">
|
||||
<div v-for="share in walletApprovedShares" class="row q-mb-xs">
|
||||
<div class="col-3 q-mt-md">
|
||||
<strong v-text="share.username"></strong>
|
||||
</div>
|
||||
<div class="col-1 q-mt-sm">
|
||||
<q-icon v-if="share.comment" name="add_comment">
|
||||
<q-tooltip v-text="share.comment"></q-tooltip>
|
||||
</q-icon>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<q-select
|
||||
v-model="share.permissions"
|
||||
:options="permissionOptions"
|
||||
emit-value
|
||||
map-options
|
||||
multiple
|
||||
use-chips
|
||||
dense
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-1 q-mt-sm">
|
||||
<q-btn
|
||||
flat
|
||||
color="red"
|
||||
icon="delete"
|
||||
outline
|
||||
class="full-width"
|
||||
@click="deleteSharePermission(share)"
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-1 q-mt-sm">
|
||||
<q-btn
|
||||
dense
|
||||
flat
|
||||
color="primary"
|
||||
icon="check"
|
||||
class="full-width"
|
||||
@click="updateSharePermissions(share)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section v-else>
|
||||
<span>This wallet is not shared with anyone.</span>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
group="wallet_shares"
|
||||
dense
|
||||
expand-separator
|
||||
icon="group_add"
|
||||
:label="'Pending Invitations (' + walletPendingInvites.length + ')'"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section v-if="walletPendingInvites.length">
|
||||
<div v-for="share in walletPendingInvites" class="row q-mb-xs">
|
||||
<div class="col-3 q-mt-md">
|
||||
<strong v-text="share.username"></strong>
|
||||
</div>
|
||||
|
||||
<div class="col-8">
|
||||
<q-select
|
||||
v-model="share.permissions"
|
||||
:options="permissionOptions"
|
||||
emit-value
|
||||
map-options
|
||||
multiple
|
||||
use-chips
|
||||
dense
|
||||
></q-select>
|
||||
</div>
|
||||
<div class="col-1 q-mt-sm">
|
||||
<q-btn
|
||||
flat
|
||||
color="red"
|
||||
icon="delete"
|
||||
outline
|
||||
class="full-width"
|
||||
@click="deleteSharePermission(share)"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-else>
|
||||
<span>No pending invites.</span>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-card-section> </q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
v-else-if="wallet.walletType == 'lightning-shared'"
|
||||
group="extras"
|
||||
icon="supervisor_account"
|
||||
:label="$t('shared_wallet')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
This wallet does not belong to you. It is a shared Lightning wallet.
|
||||
<br />
|
||||
The owner can revoke the permissions at any moment.
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-item dense class="q-pa-none">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
<strong>Shared Wallet ID: </strong
|
||||
><em
|
||||
v-text="walletIdHidden ? '****************' : wallet.sharedWalletId"
|
||||
></em>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div>
|
||||
<q-icon
|
||||
:name="walletIdHidden ? 'visibility_off' : 'visibility'"
|
||||
class="cursor-pointer"
|
||||
@click="walletIdHidden = !walletIdHidden"
|
||||
></q-icon>
|
||||
<q-icon
|
||||
name="content_copy"
|
||||
class="cursor-pointer q-ml-sm"
|
||||
@click="copyText(wallet.sharedWalletId)"
|
||||
></q-icon>
|
||||
<q-icon name="qr_code" class="cursor-pointer q-ml-sm">
|
||||
<q-popup-proxy>
|
||||
<div class="q-pa-md">
|
||||
<lnbits-qrcode
|
||||
:value="wallet.sharedWalletId"
|
||||
:show-buttons="false"
|
||||
></lnbits-qrcode>
|
||||
</div>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="row">
|
||||
<div class="col-3 q-mt-md">
|
||||
<strong>Permissions:</strong>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<q-select
|
||||
v-model="wallet.sharePermissions"
|
||||
:options="permissionOptions"
|
||||
emit-value
|
||||
map-options
|
||||
multiple
|
||||
use-chips
|
||||
dense
|
||||
disable
|
||||
></q-select>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-expansion-item
|
||||
v-else
|
||||
group="extras"
|
||||
icon="question_mark"
|
||||
:label="$t('share_wallet')"
|
||||
>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
Unknown wallet type:
|
||||
<strong v-text="wallet.walletType" class="q-ml-md"></strong>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-expansion-item>
|
||||
|
|
@ -159,6 +159,7 @@
|
|||
color="primary"
|
||||
class="q-mr-md"
|
||||
@click="showParseDialog"
|
||||
:disable="!this.g.wallet.canSendPayments"
|
||||
:label="$t('paste_request')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
|
|
@ -166,12 +167,14 @@
|
|||
color="primary"
|
||||
class="q-mr-md"
|
||||
@click="showReceiveDialog"
|
||||
:disable="!this.g.wallet.canReceivePayments"
|
||||
:label="$t('create_invoice')"
|
||||
></q-btn>
|
||||
<q-btn
|
||||
unelevated
|
||||
color="secondary"
|
||||
icon="qr_code_scanner"
|
||||
:disable="!this.g.wallet.canReceivePayments && !this.g.wallet.canSendPayments"
|
||||
@click="showCamera"
|
||||
>
|
||||
<q-tooltip
|
||||
|
|
@ -361,6 +364,8 @@
|
|||
</q-card>
|
||||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
{% include "core/_wallet_share.html" %}
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="extras"
|
||||
icon="phone_android"
|
||||
|
|
@ -509,7 +514,7 @@
|
|||
</q-expansion-item>
|
||||
<q-separator></q-separator>
|
||||
<q-expansion-item
|
||||
group="charts"
|
||||
group="extras"
|
||||
icon="insights"
|
||||
:label="$t('wallet_charts')"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,10 +8,25 @@ from fastapi import (
|
|||
HTTPException,
|
||||
)
|
||||
|
||||
from lnbits.core.crud.wallets import get_wallets_paginated
|
||||
from lnbits.core.crud.wallets import (
|
||||
create_wallet,
|
||||
get_wallets_paginated,
|
||||
)
|
||||
from lnbits.core.models import CreateWallet, KeyType, User, Wallet, WalletTypeInfo
|
||||
from lnbits.core.models.lnurl import StoredPayLink, StoredPayLinks
|
||||
from lnbits.core.models.wallets import WalletsFilters
|
||||
from lnbits.core.models.misc import SimpleStatus
|
||||
from lnbits.core.models.wallets import (
|
||||
WalletsFilters,
|
||||
WalletSharePermission,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.core.services.wallets import (
|
||||
create_lightning_shared_wallet,
|
||||
delete_wallet_share,
|
||||
invite_to_wallet,
|
||||
reject_wallet_invitation,
|
||||
update_wallet_share_permissions,
|
||||
)
|
||||
from lnbits.db import Filters, Page
|
||||
from lnbits.decorators import (
|
||||
check_user_exists,
|
||||
|
|
@ -22,7 +37,6 @@ from lnbits.decorators import (
|
|||
from lnbits.helpers import generate_filter_params_openapi
|
||||
|
||||
from ..crud import (
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_wallet,
|
||||
update_wallet,
|
||||
|
|
@ -62,6 +76,35 @@ async def api_wallets_paginated(
|
|||
return page
|
||||
|
||||
|
||||
@wallet_router.put("/share/invite")
|
||||
async def api_invite_wallet_share(
|
||||
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> WalletSharePermission:
|
||||
return await invite_to_wallet(key_info.wallet, data)
|
||||
|
||||
|
||||
@wallet_router.delete("/share/invite/{share_request_id}")
|
||||
async def api_reject_wallet_invitation(
|
||||
share_request_id: str, invited_user: User = Depends(check_user_exists)
|
||||
) -> SimpleStatus:
|
||||
await reject_wallet_invitation(invited_user.id, share_request_id)
|
||||
return SimpleStatus(success=True, message="Invitation rejected.")
|
||||
|
||||
|
||||
@wallet_router.put("/share")
|
||||
async def api_accept_wallet_share_request(
|
||||
data: WalletSharePermission, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> WalletSharePermission:
|
||||
return await update_wallet_share_permissions(key_info.wallet, data)
|
||||
|
||||
|
||||
@wallet_router.delete("/share/{share_request_id}")
|
||||
async def api_delete_wallet_share_permissions(
|
||||
share_request_id: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
) -> SimpleStatus:
|
||||
return await delete_wallet_share(key_info.wallet, share_request_id)
|
||||
|
||||
|
||||
@wallet_router.put("/{new_name}")
|
||||
async def api_update_wallet_name(
|
||||
new_name: str, key_info: WalletTypeInfo = Depends(require_admin_key)
|
||||
|
|
@ -70,6 +113,7 @@ async def api_update_wallet_name(
|
|||
if not wallet:
|
||||
raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Wallet not found")
|
||||
wallet.name = new_name
|
||||
|
||||
await update_wallet(wallet)
|
||||
return {
|
||||
"id": wallet.id,
|
||||
|
|
@ -124,6 +168,7 @@ async def api_update_wallet(
|
|||
wallet.extra.color = color or wallet.extra.color
|
||||
wallet.extra.pinned = pinned if pinned is not None else wallet.extra.pinned
|
||||
wallet.currency = currency if currency is not None else wallet.currency
|
||||
|
||||
await update_wallet(wallet)
|
||||
return wallet
|
||||
|
||||
|
|
@ -147,4 +192,21 @@ async def api_create_wallet(
|
|||
data: CreateWallet,
|
||||
key_info: WalletTypeInfo = Depends(require_admin_key),
|
||||
) -> Wallet:
|
||||
if data.wallet_type == WalletType.LIGHTNING:
|
||||
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
|
||||
|
||||
if data.wallet_type == WalletType.LIGHTNING_SHARED:
|
||||
if not data.shared_wallet_id:
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
"Shared wallet ID is required for shared wallets.",
|
||||
)
|
||||
return await create_lightning_shared_wallet(
|
||||
user_id=key_info.wallet.user,
|
||||
source_wallet_id=data.shared_wallet_id,
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
f"Unknown wallet type: {data.wallet_type}.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -594,6 +594,13 @@ class Filters(BaseModel, Generic[TFilterModel]):
|
|||
return values
|
||||
|
||||
|
||||
class DbJsonEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, Enum):
|
||||
return o.value
|
||||
return super().default(o)
|
||||
|
||||
|
||||
def insert_query(table_name: str, model: BaseModel) -> str:
|
||||
"""
|
||||
Generate an insert query with placeholders for a given table and model
|
||||
|
|
@ -648,7 +655,7 @@ def model_to_dict(model: BaseModel) -> dict:
|
|||
or type_ is dict
|
||||
or get_origin(outertype_) is list
|
||||
):
|
||||
_dict[key] = json.dumps(value)
|
||||
_dict[key] = json.dumps(value, cls=DbJsonEncoder)
|
||||
continue
|
||||
_dict[key] = value
|
||||
|
||||
|
|
|
|||
|
|
@ -399,3 +399,11 @@ def is_snake_case(v: str) -> bool:
|
|||
|
||||
def lowercase_first_letter(s: str) -> str:
|
||||
return s[:1].lower() + s[1:] if s else s
|
||||
|
||||
|
||||
def sha256s(value: str) -> str:
|
||||
"""
|
||||
SHA256 applied on a string value.
|
||||
Returns the hex as a string.
|
||||
"""
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
|
|
|||
2
lnbits/static/bundle-components.min.js
vendored
2
lnbits/static/bundle-components.min.js
vendored
File diff suppressed because one or more lines are too long
2
lnbits/static/bundle.min.js
vendored
2
lnbits/static/bundle.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -52,9 +52,17 @@ window.localisation.en = {
|
|||
stored_paylinks: 'Stored LNURL pay links',
|
||||
wallet: 'Wallet: ',
|
||||
wallet_name: 'Wallet name',
|
||||
wallet_type: 'Wallet type',
|
||||
shared_wallet: 'Shared Wallet',
|
||||
share_wallet: 'Share Wallet',
|
||||
update_permissions: 'Update Permissions',
|
||||
shared_wallet_id: 'Shared Wallet ID',
|
||||
shared_wallet_desc:
|
||||
"You have been invited to have access to someone else's wallet.",
|
||||
wallets: 'Wallets',
|
||||
exclude_wallets: 'Exclude Wallets',
|
||||
add_wallet: 'Add wallet',
|
||||
reject_wallet: 'Reject wallet',
|
||||
add_new_wallet: 'Add a new wallet',
|
||||
pin_wallet: 'Pin wallet',
|
||||
delete_wallet: 'Delete wallet',
|
||||
|
|
|
|||
|
|
@ -123,9 +123,11 @@ window._lnbitsApi = {
|
|||
getWallet(wallet) {
|
||||
return this.request('get', '/api/v1/wallet', wallet.inkey)
|
||||
},
|
||||
createWallet(wallet, name) {
|
||||
createWallet(wallet, name, walletType, ops = {}) {
|
||||
return this.request('post', '/api/v1/wallet', wallet.adminkey, {
|
||||
name: name
|
||||
name: name,
|
||||
wallet_type: walletType,
|
||||
...ops
|
||||
}).then(res => {
|
||||
window.location = '/wallet?wal=' + res.data.id
|
||||
})
|
||||
|
|
|
|||
|
|
@ -52,22 +52,33 @@ window.LNbits = {
|
|||
0,
|
||||
data.wallets.length - data.extra.visible_wallet_count
|
||||
)
|
||||
obj.walletInvitesCount = data.extra.wallet_invite_requests?.length || 0
|
||||
return obj
|
||||
},
|
||||
wallet(data) {
|
||||
newWallet = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
walletType: data.wallet_type,
|
||||
sharePermissions: data.share_permissions,
|
||||
sharedWalletId: data.shared_wallet_id,
|
||||
adminkey: data.adminkey,
|
||||
inkey: data.inkey,
|
||||
currency: data.currency,
|
||||
extra: data.extra
|
||||
extra: data.extra,
|
||||
canReceivePayments: true,
|
||||
canSendPayments: true
|
||||
}
|
||||
newWallet.msat = data.balance_msat
|
||||
newWallet.sat = Math.floor(data.balance_msat / 1000)
|
||||
newWallet.fsat = new Intl.NumberFormat(window.LOCALE).format(
|
||||
newWallet.sat
|
||||
)
|
||||
if (newWallet.walletType === 'lightning-shared') {
|
||||
const perms = newWallet.sharePermissions
|
||||
newWallet.canReceivePayments = perms.includes('receive-payments')
|
||||
newWallet.canSendPayments = perms.includes('send-payments')
|
||||
}
|
||||
newWallet.url = `/wallet?&wal=${data.id}`
|
||||
return newWallet
|
||||
},
|
||||
|
|
|
|||
|
|
@ -29,14 +29,13 @@ window.app.component('lnbits-wallet-list', {
|
|||
return {
|
||||
activeWallet: null,
|
||||
balance: 0,
|
||||
showForm: false,
|
||||
walletName: '',
|
||||
LNBITS_DENOMINATION: LNBITS_DENOMINATION
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
createWallet() {
|
||||
LNbits.api.createWallet(this.g.user.wallets[0], this.walletName)
|
||||
this.$emit('wallet-action', {action: 'create-wallet'})
|
||||
}
|
||||
},
|
||||
created() {
|
||||
|
|
|
|||
74
lnbits/static/js/components/lnbits-new-user-wallet.js
Normal file
74
lnbits/static/js/components/lnbits-new-user-wallet.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
window.app.component('lnbits-new-user-wallet', {
|
||||
props: ['form-data'],
|
||||
template: '#lnbits-new-user-wallet',
|
||||
mixins: [window.windowMixin],
|
||||
data() {
|
||||
return {
|
||||
newWallet: {walletType: 'lightning', name: '', sharedWalletId: ''}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitRejectWalletInvitation() {
|
||||
try {
|
||||
const inviteRequests = this.g.user.extra.wallet_invite_requests || []
|
||||
const invite = inviteRequests.find(
|
||||
invite => invite.to_wallet_id === this.newWallet.sharedWalletId
|
||||
)
|
||||
if (!invite) {
|
||||
Quasar.Notify.create({
|
||||
message: 'Cannot find invitation for the selected wallet.',
|
||||
type: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/api/v1/wallet/share/invite/${invite.request_id}`,
|
||||
this.g.wallet.adminkey
|
||||
)
|
||||
|
||||
Quasar.Notify.create({
|
||||
message: 'Invitation rejected.',
|
||||
type: 'positive'
|
||||
})
|
||||
this.g.user.extra.wallet_invite_requests = inviteRequests.filter(
|
||||
inv => inv.request_id !== invite.request_id
|
||||
)
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
async submitAddWallet() {
|
||||
console.log('### submitAddWallet', this.newWallet)
|
||||
const data = this.newWallet
|
||||
if (data.walletType === 'lightning' && !data.name) {
|
||||
this.$q.notify({
|
||||
message: 'Please enter a name for the wallet',
|
||||
color: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (data.walletType === 'lightning-shared' && !data.sharedWalletId) {
|
||||
this.$q.notify({
|
||||
message: 'Missing a shared wallet ID',
|
||||
color: 'warning'
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
await LNbits.api.createWallet(
|
||||
this.g.user.wallets[0],
|
||||
data.name,
|
||||
data.walletType,
|
||||
{
|
||||
shared_wallet_id: data.sharedWalletId
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn(e)
|
||||
LNbits.utils.notifyApiError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -6,7 +6,7 @@ window.PageWallets = {
|
|||
user: null,
|
||||
tab: 'wallets',
|
||||
wallets: [],
|
||||
showAddWalletDialog: {show: false},
|
||||
addWalletDialog: {show: false},
|
||||
walletsTable: {
|
||||
columns: [
|
||||
{
|
||||
|
|
@ -73,6 +73,10 @@ window.PageWallets = {
|
|||
this.walletsTable.loading = false
|
||||
}
|
||||
},
|
||||
showNewWalletDialog() {
|
||||
this.addWalletDialog = {show: true, walletType: 'lightning'}
|
||||
this.showAddNewWalletDialog() // from base.js
|
||||
},
|
||||
|
||||
goToWallet(walletId) {
|
||||
window.location = `/wallet?wal=${walletId}`
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ window.WalletPageLogic = {
|
|||
walletBalanceChart: null,
|
||||
inkeyHidden: true,
|
||||
adminkeyHidden: true,
|
||||
walletIdHidden: true,
|
||||
hasNfc: false,
|
||||
nfcReaderAbortController: null,
|
||||
isFiatPriority: false,
|
||||
|
|
@ -125,7 +126,16 @@ window.WalletPageLogic = {
|
|||
showBalanceInOut: true,
|
||||
showPaymentCountInOut: true
|
||||
},
|
||||
paymentsFilter: {}
|
||||
paymentsFilter: {},
|
||||
permissionOptions: [
|
||||
{label: 'View', value: 'view-payments'},
|
||||
{label: 'Receive', value: 'receive-payments'},
|
||||
{label: 'Send', value: 'send-payments'}
|
||||
],
|
||||
walletShareInvite: {
|
||||
unsername: '',
|
||||
permissions: []
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -168,6 +178,21 @@ window.WalletPageLogic = {
|
|||
wallet() {
|
||||
return this.g.wallet
|
||||
},
|
||||
walletApprovedShares() {
|
||||
return this.g.wallet.extra.shared_with.filter(
|
||||
s => s.status === 'approved'
|
||||
)
|
||||
},
|
||||
walletPendingRequests() {
|
||||
return this.g.wallet.extra.shared_with.filter(
|
||||
s => s.status === 'request_access'
|
||||
)
|
||||
},
|
||||
walletPendingInvites() {
|
||||
return this.g.wallet.extra.shared_with.filter(
|
||||
s => s.status === 'invite_sent'
|
||||
)
|
||||
},
|
||||
hasChartActive() {
|
||||
return (
|
||||
this.chartConfig.showBalance ||
|
||||
|
|
@ -675,6 +700,69 @@ window.WalletPageLogic = {
|
|||
LNbits.utils.notifyApiError(err)
|
||||
})
|
||||
},
|
||||
async updateSharePermissions(permission) {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/api/v1/wallet/share',
|
||||
this.g.wallet.adminkey,
|
||||
permission
|
||||
)
|
||||
Object.assign(permission, data)
|
||||
Quasar.Notify.create({
|
||||
message: 'Wallet permission updated.',
|
||||
type: 'positive'
|
||||
})
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
async inviteUserToWallet() {
|
||||
try {
|
||||
const {data} = await LNbits.api.request(
|
||||
'PUT',
|
||||
'/api/v1/wallet/share/invite',
|
||||
this.g.wallet.adminkey,
|
||||
{
|
||||
...this.walletShareInvite,
|
||||
status: 'invite_sent',
|
||||
wallet_id: this.g.wallet.id
|
||||
}
|
||||
)
|
||||
|
||||
this.g.wallet.extra.shared_with.push(data)
|
||||
this.walletShareInvite = {username: '', permissions: []}
|
||||
Quasar.Notify.create({
|
||||
message: 'User invited to wallet.',
|
||||
type: 'positive'
|
||||
})
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
},
|
||||
deleteSharePermission(permission) {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to remove this share permission?')
|
||||
.onOk(async () => {
|
||||
try {
|
||||
await LNbits.api.request(
|
||||
'DELETE',
|
||||
`/api/v1/wallet/share/${permission.request_id}`,
|
||||
this.g.wallet.adminkey
|
||||
)
|
||||
this.g.wallet.extra.shared_with =
|
||||
this.g.wallet.extra.shared_with.filter(
|
||||
value => value.wallet_id !== permission.wallet_id
|
||||
)
|
||||
Quasar.Notify.create({
|
||||
message: 'Wallet permission deleted.',
|
||||
type: 'positive'
|
||||
})
|
||||
} catch (err) {
|
||||
LNbits.utils.notifyApiError(err)
|
||||
}
|
||||
})
|
||||
},
|
||||
deleteWallet() {
|
||||
LNbits.utils
|
||||
.confirmDialog('Are you sure you want to delete this wallet?')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ window.windowMixin = {
|
|||
toggleSubs: true,
|
||||
mobileSimple: true,
|
||||
walletFlip: true,
|
||||
showAddWalletDialog: {show: false},
|
||||
addWalletDialog: {show: false, walletType: 'lightning'},
|
||||
walletTypes: [{label: 'Lightning Wallet', value: 'lightning'}],
|
||||
isUserAuthorized: false,
|
||||
isSatsDenomination: WINDOW_SETTINGS['LNBITS_DENOMINATION'] == 'sats',
|
||||
allowedThemes: WINDOW_SETTINGS['LNBITS_THEME_OPTIONS'],
|
||||
|
|
@ -46,23 +47,14 @@ window.windowMixin = {
|
|||
path: '/wallets'
|
||||
})
|
||||
},
|
||||
submitAddWallet() {
|
||||
if (
|
||||
this.showAddWalletDialog.name &&
|
||||
this.showAddWalletDialog.name.length > 0
|
||||
) {
|
||||
LNbits.api.createWallet(
|
||||
this.g.user.wallets[0],
|
||||
this.showAddWalletDialog.name
|
||||
)
|
||||
this.showAddWalletDialog = {show: false}
|
||||
} else {
|
||||
this.$q.notify({
|
||||
message: 'Please enter a name for the wallet',
|
||||
color: 'negative'
|
||||
})
|
||||
handleWalletAction(payload) {
|
||||
if (payload.action === 'create-wallet') {
|
||||
this.showAddNewWalletDialog()
|
||||
}
|
||||
},
|
||||
showAddNewWalletDialog() {
|
||||
this.addWalletDialog = {show: true, walletType: 'lightning'}
|
||||
},
|
||||
simpleMobile() {
|
||||
this.$q.localStorage.set('lnbits.mobileSimple', !this.mobileSimple)
|
||||
this.refreshRoute()
|
||||
|
|
@ -326,6 +318,12 @@ window.windowMixin = {
|
|||
if (window.user) {
|
||||
this.g.user = Vue.reactive(window.LNbits.map.user(window.user))
|
||||
}
|
||||
if (this.g.user?.extra?.wallet_invite_requests?.length) {
|
||||
this.walletTypes.push({
|
||||
label: `Lightning Wallet (Share Invite: ${this.g.user.extra.wallet_invite_requests.length})`,
|
||||
value: 'lightning-shared'
|
||||
})
|
||||
}
|
||||
if (window.wallet) {
|
||||
this.g.wallet = Vue.reactive(window.LNbits.map.wallet(window.wallet))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
"js/components/admin/lnbits-admin-site-customisation.js",
|
||||
"js/components/admin/lnbits-admin-library.js",
|
||||
"js/components/admin/lnbits-admin-audit.js",
|
||||
"js/components/lnbits-new-user-wallet.js",
|
||||
"js/components/lnbits-qrcode.js",
|
||||
"js/components/lnbits-qrcode-lnurl.js",
|
||||
"js/components/extension-settings.js",
|
||||
|
|
|
|||
|
|
@ -266,6 +266,7 @@
|
|||
<lnbits-wallet-list
|
||||
v-if="!walletFlip"
|
||||
:balance="balance"
|
||||
@wallet-action="handleWalletAction"
|
||||
></lnbits-wallet-list>
|
||||
<lnbits-manage
|
||||
:show-admin="'{{LNBITS_ADMIN_UI}}' == 'True'"
|
||||
|
|
@ -295,57 +296,31 @@
|
|||
"
|
||||
>
|
||||
<div class="row no-wrap q-pr-md">
|
||||
<q-card class="wallet-list-card">
|
||||
<q-card
|
||||
@click="showAddNewWalletDialog()"
|
||||
class="wallet-list-card cursor-pointer"
|
||||
>
|
||||
<q-card-section
|
||||
class="flex flex-center column full-height text-center"
|
||||
>
|
||||
<div>
|
||||
<q-btn
|
||||
round
|
||||
color="primary"
|
||||
icon="add"
|
||||
@click="showAddWalletDialog.show = true"
|
||||
>
|
||||
<q-btn round color="primary" icon="add">
|
||||
<q-tooltip
|
||||
><span v-text="$t('add_new_wallet')"></span
|
||||
></q-tooltip>
|
||||
</q-btn>
|
||||
<q-dialog
|
||||
v-model="showAddWalletDialog.show"
|
||||
persistent
|
||||
@hide="showAddWalletDialog = {show: false}"
|
||||
>
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('wallet_name')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-input
|
||||
<div>
|
||||
<q-badge
|
||||
@click="addWalletDialog.walletType='lightning-shared '"
|
||||
dense
|
||||
v-model="showAddWalletDialog.name"
|
||||
autofocus
|
||||
@keyup.enter="submitAddWallet()"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn
|
||||
flat
|
||||
:label="$t('cancel')"
|
||||
v-close-popup
|
||||
></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
:label="$t('add_wallet')"
|
||||
v-close-popup
|
||||
@click="submitAddWallet()"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
outline
|
||||
class="q-mt-sm"
|
||||
>
|
||||
<span
|
||||
v-text="'New wallet invite (' + g.user.walletInvitesCount + ')'"
|
||||
></span>
|
||||
</q-badge>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -421,6 +396,15 @@
|
|||
</q-card>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
<q-dialog
|
||||
v-model="addWalletDialog.show"
|
||||
position="top"
|
||||
@hide="addWalletDialog = {show: false}"
|
||||
>
|
||||
<lnbits-new-user-wallet
|
||||
:form-data="formData"
|
||||
></lnbits-new-user-wallet>
|
||||
</q-dialog>
|
||||
|
||||
<router-view v-if="isVueRoute"></router-view>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ include('components/admin/audit.vue') %} {%
|
|||
include('components/admin/extensions.vue') %} {%
|
||||
include('components/admin/library.vue') %} {%
|
||||
include('components/admin/notifications.vue') %} {%
|
||||
include('components/admin/server.vue') %}
|
||||
include('components/admin/server.vue') %} {%
|
||||
include('components/new_user_wallet.vue') %}
|
||||
|
||||
<template id="lnbits-wallet-list">
|
||||
<q-list
|
||||
|
|
@ -56,7 +57,12 @@ include('components/admin/server.vue') %}
|
|||
<strong v-text="formatBalance(walletRec.sat)"></strong>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side v-show="g.wallet && g.wallet.id === walletRec.id">
|
||||
<q-item-section
|
||||
v-if="walletRec.walletType == 'lightning-shared'"
|
||||
side
|
||||
top
|
||||
>
|
||||
<q-icon name="group" :color="walletRec.extra.color" size="xs"></q-icon>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item
|
||||
|
|
@ -75,13 +81,9 @@ include('components/admin/server.vue') %}
|
|||
></q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable @click="showForm = !showForm">
|
||||
<q-item clickable @click="createWallet()">
|
||||
<q-item-section side>
|
||||
<q-icon
|
||||
:name="showForm ? 'remove' : 'add'"
|
||||
color="grey-5"
|
||||
size="md"
|
||||
></q-icon>
|
||||
<q-icon name="add" color="grey-5" size="md"></q-icon>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label
|
||||
|
|
@ -89,25 +91,13 @@ include('components/admin/server.vue') %}
|
|||
class="text-caption"
|
||||
v-text="$t('add_new_wallet')"
|
||||
></q-item-label>
|
||||
<q-item-section v-if="g.user.walletInvitesCount" side>
|
||||
<q-badge>
|
||||
<span
|
||||
v-text="'Wallet Invite (' + g.user.walletInvitesCount + ')'"
|
||||
></span>
|
||||
</q-badge>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="showForm">
|
||||
<q-item-section>
|
||||
<q-form @submit="createWallet">
|
||||
<q-input filled dense v-model="walletName" label="Name wallet *">
|
||||
<template v-slot:append>
|
||||
<q-btn
|
||||
round
|
||||
dense
|
||||
flat
|
||||
icon="send"
|
||||
size="sm"
|
||||
@click="createWallet"
|
||||
:disable="walletName === ''"
|
||||
></q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
</q-form>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
|
|
|||
98
lnbits/templates/components/new_user_wallet.vue
Normal file
98
lnbits/templates/components/new_user_wallet.vue
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<template id="lnbits-new-user-wallet">
|
||||
<q-card class="q-pa-lg q-pt-md lnbits__dialog-card">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('add_new_wallet')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-section v-if="g.user.walletInvitesCount">
|
||||
<q-badge
|
||||
@click="newWallet.walletType = 'lightning-shared'"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
<span
|
||||
v-text="
|
||||
'You have ' +
|
||||
g.user.walletInvitesCount +
|
||||
' wallet invitation' +
|
||||
(g.user.walletInvitesCount > 1 ? 's' : '')
|
||||
"
|
||||
></span>
|
||||
</q-badge>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-select
|
||||
v-if="walletTypes.length > 1"
|
||||
:options="walletTypes"
|
||||
emit-value
|
||||
map-options
|
||||
:label="$t('wallet_type')"
|
||||
v-model="newWallet.walletType"
|
||||
dense
|
||||
></q-select>
|
||||
<q-input
|
||||
v-if="newWallet.walletType == 'lightning'"
|
||||
dense
|
||||
v-model="newWallet.name"
|
||||
:label="$t('wallet_name')"
|
||||
autofocus
|
||||
@keyup.enter="submitAddWallet()"
|
||||
class="q-mt-md"
|
||||
></q-input>
|
||||
|
||||
<q-select
|
||||
v-if="newWallet.walletType == 'lightning-shared'"
|
||||
v-model="newWallet.sharedWalletId"
|
||||
:label="$t('shared_wallet_id')"
|
||||
emit-value
|
||||
map-options
|
||||
dense
|
||||
:options="
|
||||
g.user.extra.wallet_invite_requests.map(i => ({
|
||||
label: i.to_wallet_name + ' (' + i.from_user_name + ')',
|
||||
value: i.to_wallet_id
|
||||
}))
|
||||
"
|
||||
class="q-mt-md"
|
||||
></q-select>
|
||||
<div v-if="newWallet.walletType == 'lightning-shared'" class="q-mt-md">
|
||||
<span v-text="$t('shared_wallet_desc')" class="q-mt-lg"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions class="text-primary">
|
||||
<div class="row full-width">
|
||||
<div class="col-md-4">
|
||||
<q-btn
|
||||
flat
|
||||
:label="$t('cancel')"
|
||||
class="float-left"
|
||||
v-close-popup
|
||||
></q-btn>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<q-btn
|
||||
v-if="newWallet.walletType == 'lightning-shared'"
|
||||
:disabled="!newWallet.sharedWalletId"
|
||||
flat
|
||||
:label="$t('reject_wallet')"
|
||||
v-close-popup
|
||||
color="negative"
|
||||
@click="submitRejectWalletInvitation()"
|
||||
></q-btn>
|
||||
<span v-else></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<q-btn
|
||||
flat
|
||||
:label="$t('add_wallet')"
|
||||
v-close-popup
|
||||
@click="submitAddWallet()"
|
||||
class="float-right"
|
||||
></q-btn>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</template>
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<div class="row items-center justify-between q-gutter-xs">
|
||||
<div class="col">
|
||||
<q-btn
|
||||
@click="showAddWalletDialog.show = true"
|
||||
@click="showNewWalletDialog()"
|
||||
:label="$t('add_wallet')"
|
||||
color="primary"
|
||||
>
|
||||
|
|
@ -125,35 +125,10 @@
|
|||
</div>
|
||||
|
||||
<q-dialog
|
||||
v-model="showAddWalletDialog.show"
|
||||
v-model="addWalletDialog.show"
|
||||
persistent
|
||||
@hide="showAddWalletDialog = {show: false}"
|
||||
@hide="addWalletDialog = {show: false}"
|
||||
>
|
||||
<q-card style="min-width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
<span v-text="$t('wallet_name')"></span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-pt-none">
|
||||
<q-input
|
||||
dense
|
||||
v-model="showAddWalletDialog.name"
|
||||
autofocus
|
||||
@keyup.enter="submitAddWallet()"
|
||||
></q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary">
|
||||
<q-btn flat :label="$t('cancel')" v-close-popup></q-btn>
|
||||
<q-btn
|
||||
flat
|
||||
:label="$t('add_wallet')"
|
||||
v-close-popup
|
||||
@click="submitAddWallet()"
|
||||
></q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
<lnbits-new-user-wallet></lnbits-new-user-wallet>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@
|
|||
"js/components/admin/lnbits-admin-site-customisation.js",
|
||||
"js/components/admin/lnbits-admin-library.js",
|
||||
"js/components/admin/lnbits-admin-audit.js",
|
||||
"js/components/lnbits-new-user-wallet.js",
|
||||
"js/components/lnbits-qrcode.js",
|
||||
"js/components/lnbits-qrcode-lnurl.js",
|
||||
"js/components/extension-settings.js",
|
||||
|
|
|
|||
|
|
@ -106,15 +106,7 @@ async def user_alan():
|
|||
if account:
|
||||
await delete_account(account.id)
|
||||
|
||||
account = Account(
|
||||
id=uuid4().hex,
|
||||
email="alan@lnbits.com",
|
||||
username="alan",
|
||||
)
|
||||
account.hash_password("secret1234")
|
||||
user = await create_user_account(account)
|
||||
|
||||
yield user
|
||||
yield await new_user("alan")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
|
|
@ -299,6 +291,20 @@ async def fake_payments(client, inkey_fresh_headers_to):
|
|||
return fake_data, params
|
||||
|
||||
|
||||
async def new_user(username: str | None = None) -> User:
|
||||
id_ = uuid4().hex
|
||||
username = username or f"u_{id_[:16]}"
|
||||
account = Account(
|
||||
id=id_,
|
||||
email=f"{username}@lnbits.com",
|
||||
username=username,
|
||||
)
|
||||
account.hash_password("secret1234")
|
||||
user = await create_user_account(account)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def _settings_cleanup(settings: Settings):
|
||||
settings.lnbits_allow_new_accounts = True
|
||||
settings.lnbits_allowed_users = []
|
||||
|
|
|
|||
826
tests/unit/test_services_shared_wallet.py
Normal file
826
tests/unit/test_services_shared_wallet.py
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
import pytest
|
||||
|
||||
from lnbits.core.crud.payments import get_payments
|
||||
from lnbits.core.crud.users import delete_account, get_account
|
||||
from lnbits.core.crud.wallets import (
|
||||
create_wallet,
|
||||
delete_wallet,
|
||||
get_wallet,
|
||||
get_wallets,
|
||||
update_wallet,
|
||||
)
|
||||
from lnbits.core.models.users import User
|
||||
from lnbits.core.models.wallets import (
|
||||
Wallet,
|
||||
WalletPermission,
|
||||
WalletSharePermission,
|
||||
WalletShareStatus,
|
||||
WalletType,
|
||||
)
|
||||
from lnbits.core.services.payments import (
|
||||
create_invoice,
|
||||
pay_invoice,
|
||||
update_wallet_balance,
|
||||
)
|
||||
from lnbits.core.services.wallets import (
|
||||
create_lightning_shared_wallet,
|
||||
delete_wallet_share,
|
||||
invite_to_wallet,
|
||||
reject_wallet_invitation,
|
||||
update_wallet_share_permissions,
|
||||
)
|
||||
from lnbits.exceptions import InvoiceError, PaymentError
|
||||
from tests.conftest import new_user
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_ok():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
wallet_share = await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
assert wallet_share.status == WalletShareStatus.INVITE_SENT
|
||||
|
||||
source_wallet = await get_wallet(source_wallet.id)
|
||||
assert source_wallet is not None
|
||||
share = source_wallet.extra.shared_with[0]
|
||||
assert share is not None
|
||||
assert share.request_id is not None
|
||||
assert share.username == invited_user.username
|
||||
assert share.permissions == [WalletPermission.VIEW_PAYMENTS]
|
||||
assert share.status == WalletShareStatus.INVITE_SENT
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
assert invited_user is not None
|
||||
invite_request = invited_user.extra.find_wallet_invite_request(share.request_id)
|
||||
assert invite_request is not None
|
||||
assert invite_request.request_id == share.request_id
|
||||
assert invite_request.from_user_name == owner_user.username or owner_user.email
|
||||
assert invite_request.to_wallet_id == source_wallet.id
|
||||
assert invite_request.to_wallet_name == source_wallet.name
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_twice():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="User already invited to this wallet."):
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_two_invites_to_wallet_ok():
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
owner_user_one = await new_user()
|
||||
source_wallet_one = await create_wallet(
|
||||
user_id=owner_user_one.id, wallet_name="source_wallet_one"
|
||||
)
|
||||
|
||||
wallet_share_one = await invite_to_wallet(
|
||||
source_wallet=source_wallet_one,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
assert wallet_share_one.request_id is not None
|
||||
|
||||
owner_user_two = await new_user()
|
||||
source_wallet_two = await create_wallet(
|
||||
user_id=owner_user_two.id, wallet_name="source_wallet_two"
|
||||
)
|
||||
|
||||
wallet_share_two = await invite_to_wallet(
|
||||
source_wallet=source_wallet_two,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[
|
||||
WalletPermission.VIEW_PAYMENTS,
|
||||
WalletPermission.RECEIVE_PAYMENTS,
|
||||
],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
assert wallet_share_two.request_id is not None
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
assert invited_user is not None
|
||||
assert len(invited_user.extra.wallet_invite_requests) == 2
|
||||
invite_request_one = invited_user.extra.find_wallet_invite_request(
|
||||
wallet_share_one.request_id
|
||||
)
|
||||
assert invite_request_one is not None
|
||||
assert (
|
||||
invite_request_one.from_user_name == owner_user_one.username
|
||||
or owner_user_one.email
|
||||
)
|
||||
assert invite_request_one.to_wallet_id == source_wallet_one.id
|
||||
assert invite_request_one.to_wallet_name == source_wallet_one.name
|
||||
invite_request_two = invited_user.extra.find_wallet_invite_request(
|
||||
wallet_share_two.request_id
|
||||
)
|
||||
assert invite_request_two is not None
|
||||
assert (
|
||||
invite_request_two.from_user_name == owner_user_two.username
|
||||
or owner_user_two.email
|
||||
)
|
||||
assert invite_request_two.to_wallet_id == source_wallet_two.id
|
||||
assert invite_request_two.to_wallet_name == source_wallet_two.name
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_many_invites_and_one_cancel():
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
count = 10
|
||||
source_wallets = await _create_invitations_for_user(invited_user, count)
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
assert invited_user is not None
|
||||
assert len(invited_user.extra.wallet_invite_requests) == count
|
||||
|
||||
mid_index = count // 2
|
||||
mid_wallet = source_wallets[mid_index]
|
||||
share = mid_wallet.extra.shared_with[0]
|
||||
assert share is not None
|
||||
assert share.request_id is not None
|
||||
assert invited_user.extra.find_wallet_invite_request(share.request_id) is not None
|
||||
await delete_wallet_share(mid_wallet, share.request_id)
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
assert invited_user is not None
|
||||
assert len(invited_user.extra.wallet_invite_requests) == count - 1
|
||||
assert invited_user.extra.find_wallet_invite_request(share.request_id) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_many_invites_and_one_reject():
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
count = 10
|
||||
source_wallets = await _create_invitations_for_user(invited_user, count)
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
assert invited_user is not None
|
||||
|
||||
mid_wallet = source_wallets[count // 2]
|
||||
share = mid_wallet.extra.shared_with[0]
|
||||
assert share is not None
|
||||
assert share.request_id is not None
|
||||
assert invited_user.extra.find_wallet_invite_request(share.request_id) is not None
|
||||
await reject_wallet_invitation(invited_user.id, share.request_id)
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
assert invited_user is not None
|
||||
assert len(invited_user.extra.wallet_invite_requests) == count - 1
|
||||
assert invited_user.extra.find_wallet_invite_request(share.request_id) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_many_invites_and_one_accept():
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
count = 10
|
||||
source_wallets = await _create_invitations_for_user(invited_user, count)
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
assert invited_user is not None
|
||||
|
||||
mid_wallet = source_wallets[count // 2]
|
||||
share = mid_wallet.extra.shared_with[0]
|
||||
assert share is not None
|
||||
await create_lightning_shared_wallet(invited_user.id, mid_wallet.id)
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
|
||||
assert invited_user is not None
|
||||
invited_user_wallets = await get_wallets(invited_user.id)
|
||||
assert len(invited_user_wallets) == 2
|
||||
shared_wallet = next(
|
||||
(w for w in invited_user_wallets if w.shared_wallet_id == mid_wallet.id), None
|
||||
)
|
||||
assert shared_wallet is not None
|
||||
assert share.request_id is not None
|
||||
assert shared_wallet.is_lightning_shared_wallet
|
||||
assert shared_wallet.shared_wallet_id == mid_wallet.id
|
||||
assert len(invited_user.extra.wallet_invite_requests) == count - 1
|
||||
assert invited_user.extra.find_wallet_invite_request(share.request_id) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_non_lightning_wallet():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id,
|
||||
wallet_name="source_wallet",
|
||||
wallet_type=WalletType.LIGHTNING_SHARED,
|
||||
shared_wallet_id="some_shared_wallet_id",
|
||||
)
|
||||
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
source_wallet.shared_wallet_id = "some_shared_wallet_id"
|
||||
await update_wallet(source_wallet)
|
||||
with pytest.raises(ValueError, match="Only lightning wallets can be shared."):
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_wallet_owner_not_found():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
# Remove wallet owner by setting an invalid user id
|
||||
source_wallet.user = "invalid_user_id"
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot find wallet owner."):
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_empty_permissions_ok():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
# Test with empty permissions list
|
||||
wallet_share = await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
assert wallet_share.permissions == []
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_username_is_email():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
invited_user = await new_user()
|
||||
assert invited_user.email is not None
|
||||
# Use email instead of username
|
||||
wallet_share = await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.email,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
assert wallet_share.username == invited_user.email
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_sql_injection_username():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
# Try a SQL injection-like username
|
||||
with pytest.raises(ValueError, match="Invited user not found."):
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username="' OR 1=1; --",
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_long_username():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
long_username = "a" * 256
|
||||
with pytest.raises(ValueError, match="Invited user not found."):
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=long_username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_special_characters_username():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
special_username = "!@#$%^&*()_+-=[]{}|;':,.<>/?"
|
||||
with pytest.raises(ValueError, match="Invited user not found."):
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=special_username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_invite_to_wallet_no_username():
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
special_username = ""
|
||||
with pytest.raises(ValueError, match="Username or email missing."):
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=special_username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reject_wallet_invitation_user_not_found():
|
||||
with pytest.raises(ValueError, match="Invited user not found."):
|
||||
await reject_wallet_invitation("non_existent_user_id", "some_request_id")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reject_wallet_invitation_not_found():
|
||||
invited_user = await new_user()
|
||||
with pytest.raises(ValueError, match="Invitation not found."):
|
||||
await reject_wallet_invitation(invited_user.id, "non_existent_request_id")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_wallet_share_bad_wallet_type():
|
||||
shared_wallet = await _create_shared_wallet_for_user(await new_user())
|
||||
with pytest.raises(ValueError, match="Source wallet is not a lightning wallet."):
|
||||
await delete_wallet_share(shared_wallet, "some_request_id")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_wallet_share_not_found(to_wallet: Wallet):
|
||||
with pytest.raises(ValueError, match="Wallet share not found."):
|
||||
await delete_wallet_share(to_wallet, "non_existent_request_id")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_wallet_share_invited_user_not_found():
|
||||
invited_user = await new_user()
|
||||
shared_wallet = await _create_shared_wallet_for_user(invited_user)
|
||||
assert shared_wallet.shared_wallet_id is not None
|
||||
source_wallet = await get_wallet(shared_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
request_id = source_wallet.extra.shared_with[0].request_id
|
||||
assert request_id is not None
|
||||
await delete_account(invited_user.id)
|
||||
resp = await delete_wallet_share(source_wallet, request_id)
|
||||
|
||||
assert resp.success
|
||||
assert resp.message == "Permission removed. Invited user not found."
|
||||
|
||||
source_wallet = await get_wallet(shared_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
assert source_wallet.extra.find_share_by_id(request_id) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_wallet_share_target_wallet_not_found():
|
||||
invited_user = await new_user()
|
||||
shared_wallet = await _create_shared_wallet_for_user(invited_user)
|
||||
assert shared_wallet.shared_wallet_id is not None
|
||||
source_wallet = await get_wallet(shared_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
request_id = source_wallet.extra.shared_with[0].request_id
|
||||
assert request_id is not None
|
||||
await delete_wallet(user_id=invited_user.id, wallet_id=shared_wallet.id)
|
||||
resp = await delete_wallet_share(source_wallet, request_id)
|
||||
|
||||
assert resp.success
|
||||
assert resp.message == "Permission removed. Target wallet not found."
|
||||
|
||||
source_wallet = await get_wallet(shared_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
assert source_wallet.extra.find_share_by_id(request_id) is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_wallet_share_ok():
|
||||
invited_user = await new_user()
|
||||
shared_wallet = await _create_shared_wallet_for_user(invited_user)
|
||||
assert shared_wallet.shared_wallet_id is not None
|
||||
source_wallet = await get_wallet(shared_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
request_id = source_wallet.extra.shared_with[0].request_id
|
||||
assert request_id is not None
|
||||
resp = await delete_wallet_share(source_wallet, request_id)
|
||||
|
||||
assert resp.success
|
||||
assert resp.message == "Permission removed."
|
||||
|
||||
source_wallet = await get_wallet(shared_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
assert source_wallet.extra.find_share_by_id(request_id) is None
|
||||
assert len(source_wallet.extra.shared_with) == 0
|
||||
|
||||
shared_wallet = await get_wallet(shared_wallet.id)
|
||||
assert shared_wallet is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_lightning_shared_wallet_ok():
|
||||
invited_user = await new_user()
|
||||
assert invited_user.username is not None
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[WalletPermission.RECEIVE_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
assert invited_user is not None
|
||||
assert len(invited_user.extra.wallet_invite_requests) == 1
|
||||
|
||||
mirror_wallet = await create_lightning_shared_wallet(
|
||||
user_id=invited_user.id, source_wallet_id=source_wallet.id
|
||||
)
|
||||
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.is_lightning_shared_wallet
|
||||
assert mirror_wallet.shared_wallet_id == source_wallet.id
|
||||
assert mirror_wallet.can_receive_payments is True
|
||||
assert mirror_wallet.can_view_payments is False
|
||||
assert mirror_wallet.can_send_payments is False
|
||||
|
||||
source_wallet = await get_wallet(source_wallet.id)
|
||||
assert source_wallet is not None
|
||||
share = source_wallet.extra.find_share_for_wallet(mirror_wallet.id)
|
||||
assert share is not None
|
||||
assert share.status == WalletShareStatus.APPROVED
|
||||
assert share.permissions == [WalletPermission.RECEIVE_PAYMENTS]
|
||||
|
||||
invited_user = await get_account(invited_user.id)
|
||||
assert invited_user is not None
|
||||
assert len(invited_user.extra.wallet_invite_requests) == 0
|
||||
|
||||
with pytest.raises(ValueError, match="This wallet is already shared with you."):
|
||||
mirror_wallet = await create_lightning_shared_wallet(
|
||||
user_id=invited_user.id, source_wallet_id=source_wallet.id
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_wallet_view_permissions(from_wallet: Wallet):
|
||||
invited_user = await new_user()
|
||||
mirror_wallet: Wallet | None = await _create_shared_wallet_for_user(invited_user)
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.shared_wallet_id is not None
|
||||
|
||||
assert mirror_wallet.can_view_payments is True
|
||||
assert mirror_wallet.can_send_payments is False
|
||||
assert mirror_wallet.can_receive_payments is False
|
||||
|
||||
shared_wallet_payments = await get_payments(wallet_id=mirror_wallet.id)
|
||||
assert len(shared_wallet_payments) == 0
|
||||
|
||||
payment_count = 11
|
||||
wallet_balance = 0
|
||||
|
||||
for i in range(payment_count):
|
||||
payment = await create_invoice(
|
||||
wallet_id=mirror_wallet.shared_wallet_id,
|
||||
amount=1000 + i * 100,
|
||||
memo=f"Test invoice {i}",
|
||||
)
|
||||
await pay_invoice(wallet_id=from_wallet.id, payment_request=payment.bolt11)
|
||||
wallet_balance += payment.sat
|
||||
|
||||
shared_wallet_payments = await get_payments(wallet_id=mirror_wallet.id)
|
||||
assert len(shared_wallet_payments) == payment_count
|
||||
mirror_wallet = await get_wallet(mirror_wallet.id)
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.shared_wallet_id is not None
|
||||
assert mirror_wallet.balance == wallet_balance
|
||||
|
||||
source_wallet = await get_wallet(mirror_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
assert source_wallet.balance == wallet_balance
|
||||
|
||||
with pytest.raises(
|
||||
InvoiceError, match="Wallet does not have permission to create invoices."
|
||||
):
|
||||
await create_invoice(
|
||||
wallet_id=mirror_wallet.id,
|
||||
amount=1000,
|
||||
memo="Test invoice with no permissions",
|
||||
)
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
amount=1000,
|
||||
memo="Test invoice for payment",
|
||||
)
|
||||
with pytest.raises(
|
||||
PaymentError, match="Wallet does not have permission to pay invoices."
|
||||
):
|
||||
await pay_invoice(wallet_id=mirror_wallet.id, payment_request=payment.bolt11)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_wallet_no_permissions(from_wallet: Wallet):
|
||||
invited_user = await new_user()
|
||||
mirror_wallet: Wallet | None = await _create_shared_wallet_for_user(invited_user)
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.shared_wallet_id is not None
|
||||
source_wallet = await get_wallet(mirror_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
|
||||
share = source_wallet.extra.find_share_for_wallet(mirror_wallet.id)
|
||||
assert share is not None
|
||||
share.permissions = []
|
||||
await update_wallet_share_permissions(source_wallet, share)
|
||||
|
||||
shared_wallet_payments = await get_payments(wallet_id=mirror_wallet.id)
|
||||
assert len(shared_wallet_payments) == 0
|
||||
mirror_wallet = await get_wallet(mirror_wallet.id)
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.balance == 0
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
amount=1000,
|
||||
memo="Test invoice",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
InvoiceError, match="Wallet does not have permission to create invoices."
|
||||
):
|
||||
await create_invoice(
|
||||
wallet_id=mirror_wallet.id,
|
||||
amount=1000,
|
||||
memo="Test invoice with no permissions",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
PaymentError, match="Wallet does not have permission to pay invoices."
|
||||
):
|
||||
await pay_invoice(wallet_id=mirror_wallet.id, payment_request=payment.bolt11)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_wallet_receive_permission(from_wallet: Wallet):
|
||||
invited_user = await new_user()
|
||||
mirror_wallet: Wallet | None = await _create_shared_wallet_for_user(invited_user)
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.shared_wallet_id is not None
|
||||
source_wallet = await get_wallet(mirror_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
|
||||
share = source_wallet.extra.find_share_for_wallet(mirror_wallet.id)
|
||||
assert share is not None
|
||||
share.permissions = [WalletPermission.RECEIVE_PAYMENTS]
|
||||
await update_wallet_share_permissions(source_wallet, share)
|
||||
shared_wallet_payments = await get_payments(wallet_id=mirror_wallet.id)
|
||||
# cannot view payments
|
||||
assert len(shared_wallet_payments) == 0
|
||||
mirror_wallet = await get_wallet(mirror_wallet.id)
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.balance == 0
|
||||
# ok to create invoice
|
||||
await create_invoice(
|
||||
wallet_id=mirror_wallet.id,
|
||||
amount=1000,
|
||||
memo="Test invoice with no permissions",
|
||||
)
|
||||
|
||||
payment = await create_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
amount=1000,
|
||||
memo="Test invoice",
|
||||
)
|
||||
# but not to pay
|
||||
await update_wallet_balance(mirror_wallet, 100000)
|
||||
with pytest.raises(
|
||||
PaymentError, match="Wallet does not have permission to pay invoices."
|
||||
):
|
||||
await pay_invoice(wallet_id=mirror_wallet.id, payment_request=payment.bolt11)
|
||||
|
||||
shared_wallet_payments = await get_payments(wallet_id=mirror_wallet.id)
|
||||
assert len(shared_wallet_payments) == 0
|
||||
mirror_wallet = await get_wallet(mirror_wallet.id)
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.balance == 100000
|
||||
|
||||
share = source_wallet.extra.find_share_for_wallet(mirror_wallet.id)
|
||||
assert share is not None
|
||||
share.permissions.append(WalletPermission.VIEW_PAYMENTS)
|
||||
await update_wallet_share_permissions(source_wallet, share)
|
||||
shared_wallet_payments = await get_payments(wallet_id=mirror_wallet.id)
|
||||
assert len(shared_wallet_payments) == 2
|
||||
|
||||
# check that paying is still not allowed after adding view permission
|
||||
with pytest.raises(
|
||||
PaymentError, match="Wallet does not have permission to pay invoices."
|
||||
):
|
||||
await pay_invoice(wallet_id=mirror_wallet.id, payment_request=payment.bolt11)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_shared_wallet_send_permission(from_wallet: Wallet):
|
||||
invited_user = await new_user()
|
||||
mirror_wallet: Wallet | None = await _create_shared_wallet_for_user(invited_user)
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.shared_wallet_id is not None
|
||||
source_wallet = await get_wallet(mirror_wallet.shared_wallet_id)
|
||||
assert source_wallet is not None
|
||||
|
||||
share = source_wallet.extra.find_share_for_wallet(mirror_wallet.id)
|
||||
assert share is not None
|
||||
share.permissions = [WalletPermission.SEND_PAYMENTS]
|
||||
await update_wallet_share_permissions(source_wallet, share)
|
||||
shared_wallet_payments = await get_payments(wallet_id=mirror_wallet.id)
|
||||
assert len(shared_wallet_payments) == 0
|
||||
mirror_wallet = await get_wallet(mirror_wallet.id)
|
||||
assert mirror_wallet is not None
|
||||
assert mirror_wallet.balance == 0
|
||||
# cannot create invoice
|
||||
with pytest.raises(
|
||||
InvoiceError, match="Wallet does not have permission to create invoices."
|
||||
):
|
||||
await create_invoice(
|
||||
wallet_id=mirror_wallet.id,
|
||||
amount=1000,
|
||||
memo="Test invoice with no permissions",
|
||||
)
|
||||
|
||||
# but can pay invoice
|
||||
payment = await create_invoice(
|
||||
wallet_id=from_wallet.id,
|
||||
amount=1000,
|
||||
memo="Test invoice",
|
||||
)
|
||||
await update_wallet_balance(mirror_wallet, 100000)
|
||||
await pay_invoice(wallet_id=mirror_wallet.id, payment_request=payment.bolt11)
|
||||
|
||||
share = source_wallet.extra.find_share_for_wallet(mirror_wallet.id)
|
||||
assert share is not None
|
||||
share.permissions.append(WalletPermission.VIEW_PAYMENTS)
|
||||
await update_wallet_share_permissions(source_wallet, share)
|
||||
shared_wallet_payments = await get_payments(wallet_id=mirror_wallet.id)
|
||||
assert len(shared_wallet_payments) == 2
|
||||
|
||||
# check that creating invoice is still not allowed after adding view permission
|
||||
with pytest.raises(
|
||||
InvoiceError, match="Wallet does not have permission to create invoices."
|
||||
):
|
||||
await create_invoice(
|
||||
wallet_id=mirror_wallet.id,
|
||||
amount=1000,
|
||||
memo="Test invoice with no permissions",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_lightning_shared_wallet_missing_source():
|
||||
invited_user = await new_user()
|
||||
with pytest.raises(ValueError, match="Shared wallet does not exist."):
|
||||
await create_lightning_shared_wallet(
|
||||
invited_user.id, "non_existent_source_wallet_id"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_lightning_shared_wallet_bad_type():
|
||||
invited_user = await new_user()
|
||||
shared_wallet = await _create_shared_wallet_for_user(invited_user)
|
||||
with pytest.raises(ValueError, match="Shared wallet is not a lightning wallet."):
|
||||
await create_lightning_shared_wallet(invited_user.id, shared_wallet.id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_lightning_shared_wallet_self_mirror(to_wallet: Wallet):
|
||||
with pytest.raises(ValueError, match="Cannot mirror your own wallet."):
|
||||
await create_lightning_shared_wallet(to_wallet.user, to_wallet.id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_lightning_shared_wallet_missing_invitation(to_wallet: Wallet):
|
||||
with pytest.raises(ValueError, match="Cannot find invited user."):
|
||||
await create_lightning_shared_wallet("non_existing_user", to_wallet.id)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_lightning_shared_wallet_missing_user(to_wallet: Wallet):
|
||||
invited_user = await new_user()
|
||||
with pytest.raises(ValueError, match="No invitation found for this invited user."):
|
||||
await create_lightning_shared_wallet(invited_user.id, to_wallet.id)
|
||||
|
||||
|
||||
async def _create_invitations_for_user(invited_user, count) -> list[Wallet]:
|
||||
source_wallets = []
|
||||
for i in range(count):
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name=f"source_wallet_{i}"
|
||||
)
|
||||
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
source_wallets.append(source_wallet)
|
||||
return source_wallets
|
||||
|
||||
|
||||
async def _create_shared_wallet_for_user(invited_user: User) -> Wallet:
|
||||
assert invited_user.username is not None
|
||||
owner_user = await new_user()
|
||||
source_wallet = await create_wallet(
|
||||
user_id=owner_user.id, wallet_name="source_wallet"
|
||||
)
|
||||
|
||||
await invite_to_wallet(
|
||||
source_wallet=source_wallet,
|
||||
data=WalletSharePermission(
|
||||
username=invited_user.username,
|
||||
permissions=[WalletPermission.VIEW_PAYMENTS],
|
||||
status=WalletShareStatus.INVITE_SENT,
|
||||
),
|
||||
)
|
||||
|
||||
shared_wallet = await create_lightning_shared_wallet(
|
||||
user_id=invited_user.id, source_wallet_id=source_wallet.id
|
||||
)
|
||||
return shared_wallet
|
||||
Loading…
Add table
Add a link
Reference in a new issue