feat: Shared Wallets/Joint Accounts (Issue #3297) (#3376)

This commit is contained in:
Ben Weeks 2025-11-07 20:25:03 +00:00 committed by GitHub
parent bd07f7a5ef
commit b54eedee84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 2078 additions and 163 deletions

View file

@ -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:
balance = wallet.balance_msat
else:
raise ValueError("Unknown wallet")
if not wallet or not wallet.can_view_payments:
return []
balance = wallet.balance_msat
values["wallet_id"] = wallet.source_wallet_id
else:
balance = await get_total_balance()

View file

@ -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(
query += " AND deleted = :deleted "
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):

View file

@ -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
"""
)

View file

@ -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

View file

@ -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):

View file

@ -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:

View file

@ -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,

View 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

View 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>

View file

@ -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')"
>

View file

@ -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:
return await create_wallet(user_id=key_info.wallet.user, wallet_name=data.name)
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}.",
)

View file

@ -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

View file

@ -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()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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',

View file

@ -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
})

View file

@ -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
},

View file

@ -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() {

View 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)
}
}
}
})

View file

@ -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}`

View file

@ -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?')

View file

@ -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))
}

View file

@ -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",

View file

@ -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}"
</div>
<div>
<q-badge
@click="addWalletDialog.walletType='lightning-shared '"
dense
outline
class="q-mt-sm"
>
<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>
</q-dialog>
<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>

View file

@ -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>
</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 v-if="g.user.walletInvitesCount" side>
<q-badge>
<span
v-text="'Wallet Invite (' + g.user.walletInvitesCount + ')'"
></span>
</q-badge>
</q-item-section>
</q-item-section>
</q-item>
</q-list>

View 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>

View file

@ -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>

View file

@ -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",

View file

@ -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 = []

View 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