format
This commit is contained in:
parent
ac69ae9846
commit
44d3e28182
11 changed files with 630 additions and 638 deletions
|
|
@ -13,13 +13,12 @@ Configure the extension from the "Settings" page in the top right menu when logg
|
||||||
### Configuration Options:
|
### Configuration Options:
|
||||||
|
|
||||||
| Key | Description | Default |
|
| Key | Description | Default |
|
||||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
||||||
| relay | URL of the nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to connect to the [nostrclient extension](https://github.com/lnbits/nostrclient). | nostrclient |
|
| relay | URL of the nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to connect to the [nostrclient extension](https://github.com/lnbits/nostrclient). | nostrclient |
|
||||||
| provider_key | Nostr secret key of the NWC Service Provider. | Random key generated on install |
|
| provider_key | Nostr secret key of the NWC Service Provider. | Random key generated on install |
|
||||||
| relay_alias | Relay URL to display in pairing URLs. Set if different from `relay`. | Empty (uses the `relay` value) |
|
| relay_alias | Relay URL to display in pairing URLs. Set if different from `relay`. | Empty (uses the `relay` value) |
|
||||||
| handle_missed_events | Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality. | 0 |
|
| handle_missed_events | Number of seconds to look back for processing events missed while offline. Setting it to 0 disables this functionality. | 0 |
|
||||||
|
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
>
|
>
|
||||||
> Do not change `handle_missed_events` from its default value of `0` unless you fully understand its implications.
|
> Do not change `handle_missed_events` from its default value of `0` unless you fully understand its implications.
|
||||||
|
|
@ -28,7 +27,6 @@ Configure the extension from the "Settings" page in the top right menu when logg
|
||||||
>
|
>
|
||||||
> For this reason, unless you are trying to tackle this specific issue, it is recommended to leave this setting at `0`.
|
> For this reason, unless you are trying to tackle this specific issue, it is recommended to leave this setting at `0`.
|
||||||
|
|
||||||
|
|
||||||
### Using Nostrclient
|
### Using Nostrclient
|
||||||
|
|
||||||
The extension is preconfigured to connect to the nostrclient extension. Install it on the same LNbits instance and configure it to expose public websocket endpoints. Refer to the [nostrclient documentation](https://github.com/lnbits/nostrclient) for more information.
|
The extension is preconfigured to connect to the nostrclient extension. Install it on the same LNbits instance and configure it to expose public websocket endpoints. Refer to the [nostrclient documentation](https://github.com/lnbits/nostrclient) for more information.
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ def nwcprovider_stop():
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
|
|
||||||
|
|
||||||
def nwcprovider_start():
|
def nwcprovider_start():
|
||||||
task = create_permanent_unique_task("ext_nwcprovider", handle_nwc)
|
task = create_permanent_unique_task("ext_nwcprovider", handle_nwc)
|
||||||
scheduled_tasks.append(task)
|
scheduled_tasks.append(task)
|
||||||
|
|
|
||||||
33
crud.py
33
crud.py
|
|
@ -8,14 +8,22 @@ from .models import (
|
||||||
CreateNWCKey,
|
CreateNWCKey,
|
||||||
DeleteNWC,
|
DeleteNWC,
|
||||||
GetBudgetsNWC,
|
GetBudgetsNWC,
|
||||||
|
GetNWC,
|
||||||
GetWalletNWC,
|
GetWalletNWC,
|
||||||
NWCBudget,
|
NWCBudget,
|
||||||
NWCKey,
|
NWCKey,
|
||||||
|
NWCNewBudget,
|
||||||
TrackedSpendNWC,
|
TrackedSpendNWC,
|
||||||
GetNWC,
|
|
||||||
NWCNewBudget
|
|
||||||
)
|
)
|
||||||
from .paranoia import assert_valid_wallet_id, assert_valid_pubkey, assert_sane_string, assert_valid_timestamp_seconds, assert_valid_expiration_seconds, assert_valid_msats, assert_valid_positive_int
|
from .paranoia import (
|
||||||
|
assert_sane_string,
|
||||||
|
assert_valid_expiration_seconds,
|
||||||
|
assert_valid_msats,
|
||||||
|
assert_valid_positive_int,
|
||||||
|
assert_valid_pubkey,
|
||||||
|
assert_valid_timestamp_seconds,
|
||||||
|
assert_valid_wallet_id,
|
||||||
|
)
|
||||||
|
|
||||||
db = Database("ext_nwcprovider")
|
db = Database("ext_nwcprovider")
|
||||||
|
|
||||||
|
|
@ -174,7 +182,9 @@ async def get_budgets_nwc(data: GetBudgetsNWC) -> Optional[NWCBudget]:
|
||||||
"next_cycle": next_cycle,
|
"next_cycle": next_cycle,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
tot_spent_in_range_msats = next(iter(tot_spent_in_range_msats.values())) or 0
|
tot_spent_in_range_msats = (
|
||||||
|
next(iter(tot_spent_in_range_msats.values())) or 0
|
||||||
|
)
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_msats(tot_spent_in_range_msats)
|
assert_valid_msats(tot_spent_in_range_msats)
|
||||||
|
|
@ -193,9 +203,7 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
created_at = int(time.time())
|
created_at = int(time.time())
|
||||||
budgets = await get_budgets_nwc(GetBudgetsNWC(
|
budgets = await get_budgets_nwc(GetBudgetsNWC(pubkey=data.pubkey))
|
||||||
pubkey=data.pubkey
|
|
||||||
))
|
|
||||||
in_budget = True
|
in_budget = True
|
||||||
for budget in budgets:
|
for budget in budgets:
|
||||||
last_cycle, next_cycle = budget.get_timestamp_range()
|
last_cycle, next_cycle = budget.get_timestamp_range()
|
||||||
|
|
@ -206,7 +214,10 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
tot_spent_in_range_msats = (
|
tot_spent_in_range_msats = (
|
||||||
next(iter((await db.fetchone(
|
next(
|
||||||
|
iter(
|
||||||
|
(
|
||||||
|
await db.fetchone(
|
||||||
"""
|
"""
|
||||||
SELECT SUM(amount_msats) FROM nwcprovider.spent
|
SELECT SUM(amount_msats) FROM nwcprovider.spent
|
||||||
WHERE pubkey = :pubkey AND created_at >=
|
WHERE pubkey = :pubkey AND created_at >=
|
||||||
|
|
@ -217,7 +228,11 @@ async def tracked_spend_nwc(data: TrackedSpendNWC, action):
|
||||||
"last_cycle": last_cycle,
|
"last_cycle": last_cycle,
|
||||||
"next_cycle": next_cycle,
|
"next_cycle": next_cycle,
|
||||||
},
|
},
|
||||||
)).values())) or 0
|
)
|
||||||
|
).values()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
)
|
)
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .nwcp import NWCServiceProvider
|
|
||||||
|
|
||||||
|
|
||||||
class NWCKey(BaseModel):
|
class NWCKey(BaseModel):
|
||||||
pubkey: str
|
pubkey: str
|
||||||
|
|
@ -29,8 +27,6 @@ class NWCKey(BaseModel):
|
||||||
return cls(**row)
|
return cls(**row)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class NWCBudget(BaseModel):
|
class NWCBudget(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
pubkey: str
|
pubkey: str
|
||||||
|
|
@ -111,4 +107,3 @@ class NWCRegistrationRequest(BaseModel):
|
||||||
class NWCGetResponse(BaseModel):
|
class NWCGetResponse(BaseModel):
|
||||||
data: NWCKey
|
data: NWCKey
|
||||||
budgets: List[NWCBudget]
|
budgets: List[NWCBudget]
|
||||||
|
|
||||||
|
|
|
||||||
16
nwcp.py
16
nwcp.py
|
|
@ -59,19 +59,24 @@ class MainSubscription:
|
||||||
if now - event["created_at"] > expire:
|
if now - event["created_at"] > expire:
|
||||||
del self.events[event_id]
|
del self.events[event_id]
|
||||||
deleted_ids.append(event_id)
|
deleted_ids.append(event_id)
|
||||||
self.responses = [event_id for event_id in self.responses if event_id not in deleted_ids]
|
self.responses = [
|
||||||
|
event_id for event_id in self.responses if event_id not in deleted_ids
|
||||||
|
]
|
||||||
|
|
||||||
if len(deleted_ids) > 0:
|
if len(deleted_ids) > 0:
|
||||||
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events")
|
logger.debug("Garbage collected " + str(len(deleted_ids)) + " events")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
arbitrary_types_allowed = True
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
|
|
||||||
class NWCServiceProvider:
|
class NWCServiceProvider:
|
||||||
def __init__(self, private_key: Optional[str] = None, relay: Optional[str] = None, handle_missed_events: int = 0):
|
def __init__(
|
||||||
|
self,
|
||||||
|
private_key: Optional[str] = None,
|
||||||
|
relay: Optional[str] = None,
|
||||||
|
handle_missed_events: int = 0,
|
||||||
|
):
|
||||||
if not relay: # Connect to nostrclient
|
if not relay: # Connect to nostrclient
|
||||||
relay = "nostrclient"
|
relay = "nostrclient"
|
||||||
if relay == "nostrclient":
|
if relay == "nostrclient":
|
||||||
|
|
@ -145,7 +150,6 @@ class NWCServiceProvider:
|
||||||
self.sub.gc(self.handle_missed_events)
|
self.sub.gc(self.handle_missed_events)
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
def get_supported_methods(self):
|
def get_supported_methods(self):
|
||||||
"""
|
"""
|
||||||
Returns the list of supported methods by this service provider.
|
Returns the list of supported methods by this service provider.
|
||||||
|
|
@ -270,7 +274,7 @@ class NWCServiceProvider:
|
||||||
"kinds": [23194],
|
"kinds": [23194],
|
||||||
"#p": [self.public_key_hex],
|
"#p": [self.public_key_hex],
|
||||||
# Since the last handle_missed_events seconds (handles reboots)
|
# Since the last handle_missed_events seconds (handles reboots)
|
||||||
"since": int(time.time()) - self.handle_missed_events
|
"since": int(time.time()) - self.handle_missed_events,
|
||||||
}
|
}
|
||||||
self.sub.requests_sub_id = self._get_new_subid()
|
self.sub.requests_sub_id = self._get_new_subid()
|
||||||
# Create responses subscription (needed to track previosly responded requests)
|
# Create responses subscription (needed to track previosly responded requests)
|
||||||
|
|
|
||||||
12
paranoia.py
12
paranoia.py
|
|
@ -3,12 +3,14 @@ from loguru import logger
|
||||||
|
|
||||||
ENABLE_HARDENING = True
|
ENABLE_HARDENING = True
|
||||||
|
|
||||||
|
|
||||||
def panic(reason: str):
|
def panic(reason: str):
|
||||||
if not ENABLE_HARDENING:
|
if not ENABLE_HARDENING:
|
||||||
return
|
return
|
||||||
logger.error(f"hardening: {reason}")
|
logger.error(f"hardening: {reason}")
|
||||||
raise ValueError(f"hardening: {reason}")
|
raise ValueError(f"hardening: {reason}")
|
||||||
|
|
||||||
|
|
||||||
# Throw if string contains any non-printable characters
|
# Throw if string contains any non-printable characters
|
||||||
def assert_printable(v: str):
|
def assert_printable(v: str):
|
||||||
if not ENABLE_HARDENING:
|
if not ENABLE_HARDENING:
|
||||||
|
|
@ -18,6 +20,7 @@ def assert_printable(v:str):
|
||||||
if not v.isprintable():
|
if not v.isprintable():
|
||||||
panic("string contains non-printable characters")
|
panic("string contains non-printable characters")
|
||||||
|
|
||||||
|
|
||||||
# Check if number is valid int and not NaN
|
# Check if number is valid int and not NaN
|
||||||
def assert_valid_int(v: int):
|
def assert_valid_int(v: int):
|
||||||
if not ENABLE_HARDENING:
|
if not ENABLE_HARDENING:
|
||||||
|
|
@ -25,6 +28,7 @@ def assert_valid_int(v:int):
|
||||||
if not isinstance(v, int):
|
if not isinstance(v, int):
|
||||||
panic("number is not a valid int")
|
panic("number is not a valid int")
|
||||||
|
|
||||||
|
|
||||||
# Check if number is valid positive int
|
# Check if number is valid positive int
|
||||||
def assert_valid_positive_int(v: int):
|
def assert_valid_positive_int(v: int):
|
||||||
if not ENABLE_HARDENING:
|
if not ENABLE_HARDENING:
|
||||||
|
|
@ -33,6 +37,7 @@ def assert_valid_positive_int(v:int):
|
||||||
if v < 0:
|
if v < 0:
|
||||||
panic("number is not positive")
|
panic("number is not positive")
|
||||||
|
|
||||||
|
|
||||||
# Check if number is a valid sats amount
|
# Check if number is a valid sats amount
|
||||||
def assert_valid_sats(v: int):
|
def assert_valid_sats(v: int):
|
||||||
if not ENABLE_HARDENING:
|
if not ENABLE_HARDENING:
|
||||||
|
|
@ -42,6 +47,7 @@ def assert_valid_sats(v:int):
|
||||||
if v >= max_sats_value:
|
if v >= max_sats_value:
|
||||||
panic("sats amount looks too high")
|
panic("sats amount looks too high")
|
||||||
|
|
||||||
|
|
||||||
# Check if number is a valid msats amount
|
# Check if number is a valid msats amount
|
||||||
def assert_valid_msats(v: int):
|
def assert_valid_msats(v: int):
|
||||||
if not ENABLE_HARDENING:
|
if not ENABLE_HARDENING:
|
||||||
|
|
@ -60,6 +66,7 @@ def assert_valid_sha256(v:str):
|
||||||
if len(v) != 64 or not all(c in "0123456789abcdef" for c in v):
|
if len(v) != 64 or not all(c in "0123456789abcdef" for c in v):
|
||||||
panic("string is not a valid sha256 hash")
|
panic("string is not a valid sha256 hash")
|
||||||
|
|
||||||
|
|
||||||
# Check if value is an hash of an unexpected input (eg. empty strings, booleans etc)
|
# Check if value is an hash of an unexpected input (eg. empty strings, booleans etc)
|
||||||
def assert_no_badhash(v: str):
|
def assert_no_badhash(v: str):
|
||||||
bad_hashes = [
|
bad_hashes = [
|
||||||
|
|
@ -72,6 +79,7 @@ def assert_no_badhash(v:str):
|
||||||
if v in bad_hashes:
|
if v in bad_hashes:
|
||||||
panic("bad hash detected")
|
panic("bad hash detected")
|
||||||
|
|
||||||
|
|
||||||
# Check if valid nostr pubkey
|
# Check if valid nostr pubkey
|
||||||
def assert_valid_pubkey(v: str):
|
def assert_valid_pubkey(v: str):
|
||||||
if not ENABLE_HARDENING:
|
if not ENABLE_HARDENING:
|
||||||
|
|
@ -128,6 +136,7 @@ def assert_non_empty_string(v:str):
|
||||||
if len(v.strip()) == 0:
|
if len(v.strip()) == 0:
|
||||||
panic("string is empty")
|
panic("string is empty")
|
||||||
|
|
||||||
|
|
||||||
# Assert valid json
|
# Assert valid json
|
||||||
def assert_valid_json(v: str):
|
def assert_valid_json(v: str):
|
||||||
if not ENABLE_HARDENING:
|
if not ENABLE_HARDENING:
|
||||||
|
|
@ -135,10 +144,12 @@ def assert_valid_json(v:str):
|
||||||
assert_non_empty_string(v)
|
assert_non_empty_string(v)
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
|
|
||||||
json.loads(v)
|
json.loads(v)
|
||||||
except:
|
except:
|
||||||
panic("string is not valid json")
|
panic("string is not valid json")
|
||||||
|
|
||||||
|
|
||||||
# Check if string is a valid bolt11 invoice
|
# Check if string is a valid bolt11 invoice
|
||||||
def assert_valid_bolt11(invoice: str):
|
def assert_valid_bolt11(invoice: str):
|
||||||
if not ENABLE_HARDENING:
|
if not ENABLE_HARDENING:
|
||||||
|
|
@ -152,4 +163,3 @@ def assert_boolean(v:bool):
|
||||||
return
|
return
|
||||||
if not isinstance(v, bool):
|
if not isinstance(v, bool):
|
||||||
panic("not a boolean")
|
panic("not a boolean")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,18 +63,19 @@ window.app = Vue.createApp({
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'positive',
|
type: "positive",
|
||||||
message: 'Config saved!',
|
message: "Config saved!",
|
||||||
});
|
});
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'warning',
|
type: "warning",
|
||||||
message: 'You need to restart the server for the changes to take effect!',
|
message:
|
||||||
|
"You need to restart the server for the changes to take effect!",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'negative',
|
type: "negative",
|
||||||
message: "Error saving config: " + String(error),
|
message: "Error saving config: " + String(error),
|
||||||
})
|
});
|
||||||
console.error("Error saving config:", error);
|
console.error("Error saving config:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -67,8 +67,8 @@
|
||||||
const wallet = this.getWallet();
|
const wallet = this.getWallet();
|
||||||
if (!wallet) {
|
if (!wallet) {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: 'negative',
|
type: "negative",
|
||||||
message: 'Please select a wallet first',
|
message: "Please select a wallet first",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -96,19 +96,18 @@
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: "positive",
|
type: "positive",
|
||||||
message: "URL copied to clipboard"
|
message: "URL copied to clipboard",
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: "negative",
|
type: "negative",
|
||||||
message: "Failed to copy URL."
|
message: "Failed to copy URL.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showPairingQR() {
|
showPairingQR() {
|
||||||
this.pairingQrDialog.data.pairingUrl =
|
this.pairingQrDialog.data.pairingUrl = this.pairingDialog.data.pairingUrl;
|
||||||
this.pairingDialog.data.pairingUrl;
|
|
||||||
this.pairingQrDialog.show = true;
|
this.pairingQrDialog.show = true;
|
||||||
},
|
},
|
||||||
closePairingQrDialog() {
|
closePairingQrDialog() {
|
||||||
|
|
@ -137,8 +136,7 @@
|
||||||
this.connectDialog.data.budgets.push({
|
this.connectDialog.data.budgets.push({
|
||||||
budget_sats: 1000,
|
budget_sats: 1000,
|
||||||
used_budget_sats: 0,
|
used_budget_sats: 0,
|
||||||
created_at:
|
created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
|
||||||
new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000,
|
|
||||||
expiration: "never",
|
expiration: "never",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
@ -171,8 +169,7 @@
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
deleteNWC: async function (pubkey) {
|
deleteNWC: async function (pubkey) {
|
||||||
Quasar
|
Quasar.Dialog.create({
|
||||||
.Dialog.create({
|
|
||||||
title: "Confirm Deletion",
|
title: "Confirm Deletion",
|
||||||
message: "Are you sure you want to delete this connection?",
|
message: "Are you sure you want to delete this connection?",
|
||||||
cancel: true,
|
cancel: true,
|
||||||
|
|
@ -189,7 +186,7 @@
|
||||||
this.loadNwcs();
|
this.loadNwcs();
|
||||||
Quasar.Notify.create({
|
Quasar.Notify.create({
|
||||||
type: "positive",
|
type: "positive",
|
||||||
message: "Deleted successfully"
|
message: "Deleted successfully",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
LNbits.utils.notifyApiError(error);
|
LNbits.utils.notifyApiError(error);
|
||||||
|
|
|
||||||
57
tasks.py
57
tasks.py
|
|
@ -19,10 +19,20 @@ from loguru import logger
|
||||||
|
|
||||||
from .crud import get_config_nwc, get_nwc, tracked_spend_nwc
|
from .crud import get_config_nwc, get_nwc, tracked_spend_nwc
|
||||||
from .execution_queue import execution_queue
|
from .execution_queue import execution_queue
|
||||||
from .models import NWCKey, TrackedSpendNWC, GetNWC
|
from .models import GetNWC, NWCKey, TrackedSpendNWC
|
||||||
from .nwcp import NWCServiceProvider
|
from .nwcp import NWCServiceProvider
|
||||||
|
from .paranoia import (
|
||||||
|
assert_boolean,
|
||||||
|
assert_sane_string,
|
||||||
|
assert_valid_bolt11,
|
||||||
|
assert_valid_expiration_seconds,
|
||||||
|
assert_valid_msats,
|
||||||
|
assert_valid_positive_int,
|
||||||
|
assert_valid_pubkey,
|
||||||
|
assert_valid_sha256,
|
||||||
|
assert_valid_wallet_id,
|
||||||
|
)
|
||||||
from .permission import nwc_permissions
|
from .permission import nwc_permissions
|
||||||
from .paranoia import assert_valid_wallet_id, assert_valid_pubkey, assert_boolean, assert_sane_string, assert_valid_expiration_seconds, assert_valid_msats, assert_valid_positive_int, assert_valid_bolt11, assert_valid_sha256
|
|
||||||
|
|
||||||
|
|
||||||
async def _check(nwc: Optional[NWCKey], method: str) -> Optional[Dict]:
|
async def _check(nwc: Optional[NWCKey], method: str) -> Optional[Dict]:
|
||||||
|
|
@ -78,10 +88,7 @@ async def _process_invoice(
|
||||||
payment_hash = None
|
payment_hash = None
|
||||||
try:
|
try:
|
||||||
in_budget, payment_hash = await tracked_spend_nwc(
|
in_budget, payment_hash = await tracked_spend_nwc(
|
||||||
TrackedSpendNWC(
|
TrackedSpendNWC(pubkey=pubkey, amount_msats=amount_msats), execute_payment
|
||||||
pubkey=pubkey,
|
|
||||||
amount_msats=amount_msats
|
|
||||||
), execute_payment
|
|
||||||
)
|
)
|
||||||
if not in_budget:
|
if not in_budget:
|
||||||
error = {
|
error = {
|
||||||
|
|
@ -121,19 +128,14 @@ async def _process_invoice(
|
||||||
|
|
||||||
|
|
||||||
async def _on_pay_invoice(
|
async def _on_pay_invoice(
|
||||||
sp: NWCServiceProvider,
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
pubkey: str,
|
|
||||||
payload: Dict
|
|
||||||
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(pubkey)
|
assert_valid_pubkey(pubkey)
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
nwc = await get_nwc(GetNWC(
|
nwc = await get_nwc(GetNWC(pubkey=pubkey, refresh_last_used=True))
|
||||||
pubkey=pubkey,
|
|
||||||
refresh_last_used=True
|
|
||||||
))
|
|
||||||
error = await _check(nwc, "pay_invoice")
|
error = await _check(nwc, "pay_invoice")
|
||||||
if error:
|
if error:
|
||||||
return [(None, error, [])]
|
return [(None, error, [])]
|
||||||
|
|
@ -167,9 +169,7 @@ async def _on_pay_invoice(
|
||||||
|
|
||||||
|
|
||||||
async def _on_multi_pay_invoice(
|
async def _on_multi_pay_invoice(
|
||||||
sp: NWCServiceProvider,
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
pubkey: str,
|
|
||||||
payload: Dict
|
|
||||||
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
@ -228,9 +228,7 @@ async def _on_multi_pay_invoice(
|
||||||
|
|
||||||
|
|
||||||
async def _on_make_invoice(
|
async def _on_make_invoice(
|
||||||
sp: NWCServiceProvider,
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
pubkey: str,
|
|
||||||
payload: Dict
|
|
||||||
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
@ -300,9 +298,7 @@ async def _on_make_invoice(
|
||||||
|
|
||||||
|
|
||||||
async def _on_lookup_invoice(
|
async def _on_lookup_invoice(
|
||||||
sp: NWCServiceProvider,
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
pubkey: str,
|
|
||||||
payload: Dict
|
|
||||||
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
@ -339,7 +335,10 @@ async def _on_lookup_invoice(
|
||||||
is_settled = not payment.pending
|
is_settled = not payment.pending
|
||||||
timestamp = int(payment.time.timestamp()) or int(invoice_data.date)
|
timestamp = int(payment.time.timestamp()) or int(invoice_data.date)
|
||||||
expiry = int(payment.expiry.timestamp()) or timestamp + 3600
|
expiry = int(payment.expiry.timestamp()) or timestamp + 3600
|
||||||
preimage = payment.preimage or "0000000000000000000000000000000000000000000000000000000000000000"
|
preimage = (
|
||||||
|
payment.preimage
|
||||||
|
or "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
)
|
||||||
res: Dict = {
|
res: Dict = {
|
||||||
"type": "outgoing" if payment.is_out else "incoming",
|
"type": "outgoing" if payment.is_out else "incoming",
|
||||||
"invoice": payment.bolt11,
|
"invoice": payment.bolt11,
|
||||||
|
|
@ -362,9 +361,7 @@ async def _on_lookup_invoice(
|
||||||
|
|
||||||
|
|
||||||
async def _on_list_transactions(
|
async def _on_list_transactions(
|
||||||
sp: NWCServiceProvider,
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
pubkey: str,
|
|
||||||
payload: Dict
|
|
||||||
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_valid_pubkey(pubkey)
|
assert_valid_pubkey(pubkey)
|
||||||
|
|
@ -435,9 +432,7 @@ async def _on_list_transactions(
|
||||||
|
|
||||||
|
|
||||||
async def _on_get_balance(
|
async def _on_get_balance(
|
||||||
sp: NWCServiceProvider,
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
pubkey: str,
|
|
||||||
payload: Dict
|
|
||||||
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
@ -460,9 +455,7 @@ async def _on_get_balance(
|
||||||
|
|
||||||
|
|
||||||
async def _on_get_info(
|
async def _on_get_info(
|
||||||
sp: NWCServiceProvider,
|
sp: NWCServiceProvider, pubkey: str, payload: Dict
|
||||||
pubkey: str,
|
|
||||||
payload: Dict
|
|
||||||
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
) -> List[Tuple[Optional[Dict], Optional[Dict], List]]:
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
|
|
|
||||||
|
|
@ -852,7 +852,6 @@ async def test_budget_refresh():
|
||||||
await wallet1.close()
|
await wallet1.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Mostly AI generated pentests
|
# Mostly AI generated pentests
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -882,7 +881,7 @@ async def test_idor_vulnerability():
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}",
|
f"http://localhost:5002/nwcprovider/api/v1/nwc/{nwc_wallet1['pubkey']}",
|
||||||
headers={"X-Api-Key": wallets["wallet2"]["admin_key"]}
|
headers={"X-Api-Key": wallets["wallet2"]["admin_key"]},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 500
|
assert resp.status_code == 500
|
||||||
assert "Pubkey has no associated wallet" in resp.text
|
assert "Pubkey has no associated wallet" in resp.text
|
||||||
|
|
@ -896,7 +895,7 @@ async def test_sql_injection():
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
f"http://localhost:5002/nwcprovider/api/v1/nwc/{malicious_pubkey}",
|
f"http://localhost:5002/nwcprovider/api/v1/nwc/{malicious_pubkey}",
|
||||||
headers={"X-Api-Key": wallets["wallet1"]["admin_key"]},
|
headers={"X-Api-Key": wallets["wallet1"]["admin_key"]},
|
||||||
json={"permissions": ["pay"], "description": "test"}
|
json={"permissions": ["pay"], "description": "test"},
|
||||||
)
|
)
|
||||||
# Should be rejected by input validation
|
# Should be rejected by input validation
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|
@ -941,8 +940,14 @@ async def test_budget_bypass():
|
||||||
"wallet1",
|
"wallet1",
|
||||||
"test_budget_bypass",
|
"test_budget_bypass",
|
||||||
["pay", "invoice"],
|
["pay", "invoice"],
|
||||||
[{"budget_msats": 100000, "refresh_window": 3600, "created_at": int(time.time())}],
|
[
|
||||||
0
|
{
|
||||||
|
"budget_msats": 100000,
|
||||||
|
"refresh_window": 3600,
|
||||||
|
"created_at": int(time.time()),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
0,
|
||||||
)
|
)
|
||||||
wallet = NWCWallet(nwc["pairing"])
|
wallet = NWCWallet(nwc["pairing"])
|
||||||
await wallet.start()
|
await wallet.start()
|
||||||
|
|
@ -964,14 +969,16 @@ async def test_budget_bypass():
|
||||||
async def test_unauthorized_config():
|
async def test_unauthorized_config():
|
||||||
"""Test unauthorized access to config endpoint"""
|
"""Test unauthorized access to config endpoint"""
|
||||||
malicious_relay = "ws://attacker-relay.example"
|
malicious_relay = "ws://attacker-relay.example"
|
||||||
|
|
||||||
async def set_config_nwc(key: str, value: str):
|
async def set_config_nwc(key: str, value: str):
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"http://localhost:5002/nwcprovider/api/v1/config",
|
"http://localhost:5002/nwcprovider/api/v1/config",
|
||||||
json={key: value},
|
json={key: value},
|
||||||
headers={"X-Api-Key": "lnbitsadmin"} # Assuming admin key
|
headers={"X-Api-Key": "lnbitsadmin"}, # Assuming admin key
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
await set_config_nwc("relay", malicious_relay)
|
await set_config_nwc("relay", malicious_relay)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
91
views_api.py
91
views_api.py
|
|
@ -2,7 +2,7 @@ from http import HTTPStatus
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
import secp256k1
|
import secp256k1
|
||||||
from fastapi import APIRouter, Depends, Request, Query
|
from fastapi import APIRouter, Depends, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from lnbits.core.models import WalletTypeInfo
|
from lnbits.core.models import WalletTypeInfo
|
||||||
from lnbits.decorators import check_admin, require_admin_key
|
from lnbits.decorators import check_admin, require_admin_key
|
||||||
|
|
@ -18,35 +18,34 @@ from .crud import (
|
||||||
set_config_nwc,
|
set_config_nwc,
|
||||||
)
|
)
|
||||||
from .models import (
|
from .models import (
|
||||||
NWCRegistrationRequest,
|
|
||||||
GetBudgetsNWC,
|
|
||||||
GetWalletNWC,
|
|
||||||
NWCGetResponse,
|
|
||||||
CreateNWCKey,
|
CreateNWCKey,
|
||||||
DeleteNWC,
|
DeleteNWC,
|
||||||
|
GetBudgetsNWC,
|
||||||
GetNWC,
|
GetNWC,
|
||||||
GetBudgetsNWC
|
GetWalletNWC,
|
||||||
|
NWCGetResponse,
|
||||||
|
NWCRegistrationRequest,
|
||||||
|
)
|
||||||
|
from .paranoia import (
|
||||||
|
assert_boolean,
|
||||||
|
assert_sane_string,
|
||||||
|
assert_valid_pubkey,
|
||||||
|
assert_valid_wallet_id,
|
||||||
)
|
)
|
||||||
from .permission import nwc_permissions
|
from .permission import nwc_permissions
|
||||||
from .paranoia import assert_valid_wallet_id, assert_valid_pubkey, assert_sane_string, assert_boolean
|
|
||||||
|
|
||||||
nwcprovider_api_router = APIRouter()
|
nwcprovider_api_router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
# Get supported permissions
|
# Get supported permissions
|
||||||
@nwcprovider_api_router.get(
|
@nwcprovider_api_router.get("/api/v1/permissions", status_code=HTTPStatus.OK)
|
||||||
"/api/v1/permissions",
|
|
||||||
status_code=HTTPStatus.OK
|
|
||||||
)
|
|
||||||
async def api_get_permissions() -> Dict:
|
async def api_get_permissions() -> Dict:
|
||||||
return nwc_permissions
|
return nwc_permissions
|
||||||
|
|
||||||
|
|
||||||
## Get nwc keys associated with the wallet
|
## Get nwc keys associated with the wallet
|
||||||
@nwcprovider_api_router.get(
|
@nwcprovider_api_router.get(
|
||||||
"/api/v1/nwc",
|
"/api/v1/nwc", status_code=HTTPStatus.OK, response_model=List[NWCGetResponse]
|
||||||
status_code=HTTPStatus.OK,
|
|
||||||
response_model=List[NWCGetResponse]
|
|
||||||
)
|
)
|
||||||
async def api_get_nwcs(
|
async def api_get_nwcs(
|
||||||
include_expired: bool = False,
|
include_expired: bool = False,
|
||||||
|
|
@ -61,16 +60,12 @@ async def api_get_nwcs(
|
||||||
assert_boolean(calculate_spent_budget)
|
assert_boolean(calculate_spent_budget)
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
wallet_nwcs = GetWalletNWC(
|
wallet_nwcs = GetWalletNWC(wallet=wallet_id, include_expired=include_expired)
|
||||||
wallet=wallet_id,
|
|
||||||
include_expired=include_expired
|
|
||||||
)
|
|
||||||
nwcs = await get_wallet_nwcs(wallet_nwcs)
|
nwcs = await get_wallet_nwcs(wallet_nwcs)
|
||||||
out = []
|
out = []
|
||||||
for nwc in nwcs:
|
for nwc in nwcs:
|
||||||
budgets_nwc = GetBudgetsNWC(
|
budgets_nwc = GetBudgetsNWC(
|
||||||
pubkey=nwc.pubkey,
|
pubkey=nwc.pubkey, calculate_spent=calculate_spent_budget
|
||||||
calculate_spent=calculate_spent_budget
|
|
||||||
)
|
)
|
||||||
budgets = await get_budgets_nwc(budgets_nwc)
|
budgets = await get_budgets_nwc(budgets_nwc)
|
||||||
res = NWCGetResponse(data=nwc, budgets=budgets)
|
res = NWCGetResponse(data=nwc, budgets=budgets)
|
||||||
|
|
@ -80,14 +75,12 @@ async def api_get_nwcs(
|
||||||
|
|
||||||
# Get a nwc key
|
# Get a nwc key
|
||||||
@nwcprovider_api_router.get(
|
@nwcprovider_api_router.get(
|
||||||
"/api/v1/nwc/{pubkey}",
|
"/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK, response_model=NWCGetResponse
|
||||||
status_code=HTTPStatus.OK,
|
|
||||||
response_model=NWCGetResponse
|
|
||||||
)
|
)
|
||||||
async def api_get_nwc(
|
async def api_get_nwc(
|
||||||
pubkey: str,
|
pubkey: str,
|
||||||
include_expired: bool = False,
|
include_expired: bool = False,
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key)
|
wallet: WalletTypeInfo = Depends(require_admin_key),
|
||||||
) -> NWCGetResponse:
|
) -> NWCGetResponse:
|
||||||
wallet_id = wallet.wallet.id
|
wallet_id = wallet.wallet.id
|
||||||
|
|
||||||
|
|
@ -97,27 +90,22 @@ async def api_get_nwc(
|
||||||
assert_valid_wallet_id(wallet_id)
|
assert_valid_wallet_id(wallet_id)
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
nwc = await get_nwc(GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired))
|
nwc = await get_nwc(
|
||||||
|
GetNWC(pubkey=pubkey, wallet=wallet_id, include_expired=include_expired)
|
||||||
|
)
|
||||||
if not nwc:
|
if not nwc:
|
||||||
raise Exception("Pubkey has no associated wallet")
|
raise Exception("Pubkey has no associated wallet")
|
||||||
res = NWCGetResponse(data=nwc, budgets=await get_budgets_nwc(
|
res = NWCGetResponse(
|
||||||
GetBudgetsNWC(
|
data=nwc, budgets=await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
|
||||||
pubkey=pubkey
|
|
||||||
)
|
)
|
||||||
))
|
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
# Get pairing url for given secret
|
# Get pairing url for given secret
|
||||||
@nwcprovider_api_router.get(
|
@nwcprovider_api_router.get(
|
||||||
"/api/v1/pairing/{secret}",
|
"/api/v1/pairing/{secret}", status_code=HTTPStatus.OK, response_model=str
|
||||||
status_code=HTTPStatus.OK,
|
|
||||||
response_model=str
|
|
||||||
)
|
)
|
||||||
async def api_get_pairing_url(
|
async def api_get_pairing_url(req: Request, secret: str) -> str:
|
||||||
req: Request,
|
|
||||||
secret: str
|
|
||||||
) -> str:
|
|
||||||
|
|
||||||
# hardening #
|
# hardening #
|
||||||
assert_sane_string(secret)
|
assert_sane_string(secret)
|
||||||
|
|
@ -183,23 +171,15 @@ async def api_register_nwc(
|
||||||
budgets=data.budgets,
|
budgets=data.budgets,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
budgets = await get_budgets_nwc(
|
budgets = await get_budgets_nwc(GetBudgetsNWC(pubkey=pubkey))
|
||||||
GetBudgetsNWC(
|
|
||||||
pubkey=pubkey
|
|
||||||
)
|
|
||||||
)
|
|
||||||
res = NWCGetResponse(data=nwc, budgets=budgets)
|
res = NWCGetResponse(data=nwc, budgets=budgets)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
# Delete a nwc key
|
# Delete a nwc key
|
||||||
@nwcprovider_api_router.delete(
|
@nwcprovider_api_router.delete("/api/v1/nwc/{pubkey}", status_code=HTTPStatus.OK)
|
||||||
"/api/v1/nwc/{pubkey}",
|
|
||||||
status_code=HTTPStatus.OK
|
|
||||||
)
|
|
||||||
async def api_delete_nwc(
|
async def api_delete_nwc(
|
||||||
pubkey: str,
|
pubkey: str, wallet: WalletTypeInfo = Depends(require_admin_key)
|
||||||
wallet: WalletTypeInfo = Depends(require_admin_key)
|
|
||||||
):
|
):
|
||||||
wallet_id = wallet.wallet.id
|
wallet_id = wallet.wallet.id
|
||||||
|
|
||||||
|
|
@ -208,20 +188,13 @@ async def api_delete_nwc(
|
||||||
assert_valid_wallet_id(wallet_id)
|
assert_valid_wallet_id(wallet_id)
|
||||||
# ## #
|
# ## #
|
||||||
|
|
||||||
await delete_nwc(DeleteNWC(
|
await delete_nwc(DeleteNWC(pubkey=pubkey, wallet=wallet_id))
|
||||||
pubkey=pubkey,
|
return JSONResponse(content={"message": f"NWC key {pubkey} deleted successfully."})
|
||||||
wallet=wallet_id
|
|
||||||
))
|
|
||||||
return JSONResponse(
|
|
||||||
content={"message": f"NWC key {pubkey} deleted successfully."}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Get config
|
# Get config
|
||||||
@nwcprovider_api_router.get(
|
@nwcprovider_api_router.get(
|
||||||
"/api/v1/config",
|
"/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
|
||||||
status_code=HTTPStatus.OK,
|
|
||||||
dependencies=[Depends(check_admin)]
|
|
||||||
)
|
)
|
||||||
async def api_get_all_config_nwc():
|
async def api_get_all_config_nwc():
|
||||||
config = await get_all_config_nwc()
|
config = await get_all_config_nwc()
|
||||||
|
|
@ -243,9 +216,7 @@ async def api_get_config_nwc(key: str):
|
||||||
|
|
||||||
# Set config
|
# Set config
|
||||||
@nwcprovider_api_router.post(
|
@nwcprovider_api_router.post(
|
||||||
"/api/v1/config",
|
"/api/v1/config", status_code=HTTPStatus.OK, dependencies=[Depends(check_admin)]
|
||||||
status_code=HTTPStatus.OK,
|
|
||||||
dependencies=[Depends(check_admin)]
|
|
||||||
)
|
)
|
||||||
async def api_set_config_nwc(req: Request):
|
async def api_set_config_nwc(req: Request):
|
||||||
data = await req.json()
|
data = await req.json()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue