branta 3.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- branta/__init__.py +45 -0
- branta/enums.py +23 -0
- branta/exceptions.py +6 -0
- branta/extensions.py +97 -0
- branta/models.py +58 -0
- branta/options.py +14 -0
- branta/v2/__init__.py +26 -0
- branta/v2/builder.py +57 -0
- branta/v2/client.py +163 -0
- branta/v2/encryption.py +58 -0
- branta/v2/parser.py +162 -0
- branta/v2/secret_generator.py +10 -0
- branta/v2/serialization.py +109 -0
- branta/v2/service.py +290 -0
- branta-3.2.0.dist-info/METADATA +152 -0
- branta-3.2.0.dist-info/RECORD +18 -0
- branta-3.2.0.dist-info/WHEEL +4 -0
- branta-3.2.0.dist-info/licenses/LICENSE +21 -0
branta/__init__.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from branta.enums import BrantaServerBaseUrl, DestinationType, PrivacyMode
|
|
2
|
+
from branta.exceptions import BrantaPaymentException, QRParseException
|
|
3
|
+
from branta.extensions import (
|
|
4
|
+
get_api_key,
|
|
5
|
+
get_base_url,
|
|
6
|
+
get_hash_zk_type,
|
|
7
|
+
get_hmac_secret,
|
|
8
|
+
get_privacy,
|
|
9
|
+
get_url,
|
|
10
|
+
is_ark,
|
|
11
|
+
is_bolt11,
|
|
12
|
+
is_silent_payment,
|
|
13
|
+
to_normalized_hash,
|
|
14
|
+
to_url_fragment,
|
|
15
|
+
)
|
|
16
|
+
from branta.models import AddPaymentResult, Destination, Payment, PaymentsResult, Platform
|
|
17
|
+
from branta.options import BrantaClientOptions
|
|
18
|
+
from branta.v2.encryption import AesEncryption, AesEncryptionService
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BrantaServerBaseUrl",
|
|
22
|
+
"DestinationType",
|
|
23
|
+
"PrivacyMode",
|
|
24
|
+
"BrantaPaymentException",
|
|
25
|
+
"QRParseException",
|
|
26
|
+
"BrantaClientOptions",
|
|
27
|
+
"Payment",
|
|
28
|
+
"Destination",
|
|
29
|
+
"Platform",
|
|
30
|
+
"PaymentsResult",
|
|
31
|
+
"AddPaymentResult",
|
|
32
|
+
"AesEncryption",
|
|
33
|
+
"AesEncryptionService",
|
|
34
|
+
"get_url",
|
|
35
|
+
"get_base_url",
|
|
36
|
+
"get_privacy",
|
|
37
|
+
"get_api_key",
|
|
38
|
+
"get_hmac_secret",
|
|
39
|
+
"get_hash_zk_type",
|
|
40
|
+
"is_bolt11",
|
|
41
|
+
"is_ark",
|
|
42
|
+
"is_silent_payment",
|
|
43
|
+
"to_normalized_hash",
|
|
44
|
+
"to_url_fragment",
|
|
45
|
+
]
|
branta/enums.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BrantaServerBaseUrl(str, Enum):
|
|
5
|
+
Staging = "https://staging.guardrail.branta.pro"
|
|
6
|
+
Production = "https://guardrail.branta.pro"
|
|
7
|
+
Localhost = "http://localhost:3000"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PrivacyMode(str, Enum):
|
|
11
|
+
Strict = "strict"
|
|
12
|
+
Loose = "loose"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DestinationType(str, Enum):
|
|
16
|
+
BitcoinAddress = "bitcoin_address"
|
|
17
|
+
Bolt11 = "bolt11"
|
|
18
|
+
Bolt12 = "bolt12"
|
|
19
|
+
LnUrl = "ln_url"
|
|
20
|
+
TetherAddress = "tether_address"
|
|
21
|
+
LnAddress = "ln_address"
|
|
22
|
+
ArkAddress = "ark_address"
|
|
23
|
+
SilentPayment = "silent_payment"
|
branta/exceptions.py
ADDED
branta/extensions.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import TYPE_CHECKING, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from branta.enums import DestinationType, PrivacyMode
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from branta.options import BrantaClientOptions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_url(server: BrantaServerBaseUrl) -> str:
|
|
13
|
+
return server.value
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_base_url(
|
|
17
|
+
default_options: Optional[BrantaClientOptions],
|
|
18
|
+
override_options: Optional[BrantaClientOptions],
|
|
19
|
+
) -> str:
|
|
20
|
+
base_url = None
|
|
21
|
+
if override_options is not None and override_options.base_url is not None:
|
|
22
|
+
base_url = override_options.base_url
|
|
23
|
+
elif default_options is not None and default_options.base_url is not None:
|
|
24
|
+
base_url = default_options.base_url
|
|
25
|
+
if base_url is None:
|
|
26
|
+
raise ValueError("Branta: base_url is a required option.")
|
|
27
|
+
return get_url(base_url)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_privacy(
|
|
31
|
+
default_options: Optional[BrantaClientOptions],
|
|
32
|
+
override_options: Optional[BrantaClientOptions],
|
|
33
|
+
fallback: PrivacyMode = PrivacyMode.Strict,
|
|
34
|
+
) -> PrivacyMode:
|
|
35
|
+
if override_options is not None and override_options.privacy is not None:
|
|
36
|
+
return override_options.privacy
|
|
37
|
+
if default_options is not None and default_options.privacy is not None:
|
|
38
|
+
return default_options.privacy
|
|
39
|
+
return fallback
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_api_key(
|
|
43
|
+
default_options: Optional[BrantaClientOptions],
|
|
44
|
+
override_options: Optional[BrantaClientOptions],
|
|
45
|
+
) -> Optional[str]:
|
|
46
|
+
if override_options is not None and override_options.default_api_key is not None:
|
|
47
|
+
return override_options.default_api_key
|
|
48
|
+
if default_options is not None and default_options.default_api_key is not None:
|
|
49
|
+
return default_options.default_api_key
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_hmac_secret(
|
|
54
|
+
default_options: Optional[BrantaClientOptions],
|
|
55
|
+
override_options: Optional[BrantaClientOptions],
|
|
56
|
+
) -> Optional[str]:
|
|
57
|
+
if override_options is not None and override_options.hmac_secret is not None:
|
|
58
|
+
return override_options.hmac_secret
|
|
59
|
+
if default_options is not None and default_options.hmac_secret is not None:
|
|
60
|
+
return default_options.hmac_secret
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_bolt11(value: str) -> bool:
|
|
65
|
+
lower = value.lower()
|
|
66
|
+
return lower.startswith("lnbc") or lower.startswith("lntb") or lower.startswith("lnbcrt")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def is_ark(value: str) -> bool:
|
|
70
|
+
return value.lower().startswith("ark1")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_silent_payment(value: str) -> bool:
|
|
74
|
+
lower = value.lower()
|
|
75
|
+
return lower.startswith("sp1") or lower.startswith("tsp1")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_hash_zk_type(value: str) -> Optional[DestinationType]:
|
|
79
|
+
if is_bolt11(value):
|
|
80
|
+
return DestinationType.Bolt11
|
|
81
|
+
if is_ark(value):
|
|
82
|
+
return DestinationType.ArkAddress
|
|
83
|
+
if is_silent_payment(value):
|
|
84
|
+
return DestinationType.SilentPayment
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def to_normalized_hash(value: str) -> str:
|
|
89
|
+
normalized = value.lower()
|
|
90
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest().upper()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def to_url_fragment(keys: Dict[str, str]) -> str:
|
|
94
|
+
if not keys:
|
|
95
|
+
return ""
|
|
96
|
+
fragments = [f"k-{k}={v}" for k, v in keys.items()]
|
|
97
|
+
return "#" + "&".join(fragments)
|
branta/models.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from branta.enums import DestinationType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Destination:
|
|
11
|
+
value: str
|
|
12
|
+
is_primary: bool = False
|
|
13
|
+
is_zk: bool = False
|
|
14
|
+
is_encrypted: Optional[bool] = None
|
|
15
|
+
type: Optional[DestinationType] = None
|
|
16
|
+
zk_id: Optional[str] = None
|
|
17
|
+
encrypted_dek: Optional[str] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Platform:
|
|
22
|
+
name: Optional[str] = None
|
|
23
|
+
logo_url: Optional[str] = None
|
|
24
|
+
logo_light_url: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Payment:
|
|
29
|
+
destinations: List[Destination] = field(default_factory=list)
|
|
30
|
+
description: Optional[str] = None
|
|
31
|
+
created_date: Optional[str] = None
|
|
32
|
+
ttl: Optional[int] = None
|
|
33
|
+
metadata: Optional[str] = None
|
|
34
|
+
platform: Optional[str] = None
|
|
35
|
+
platform_logo_url: Optional[str] = None
|
|
36
|
+
platform_logo_light_url: Optional[str] = None
|
|
37
|
+
parent_platform: Optional[Platform] = None
|
|
38
|
+
child_platform: Optional[Platform] = None
|
|
39
|
+
btc_pay_server_plugin_version: Optional[str] = None
|
|
40
|
+
is_metadata_decrypted: Optional[bool] = None
|
|
41
|
+
|
|
42
|
+
def get_default_value(self) -> str:
|
|
43
|
+
if not self.destinations:
|
|
44
|
+
raise ValueError("Payment has no destinations")
|
|
45
|
+
return self.destinations[0].value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class PaymentsResult:
|
|
50
|
+
payments: List[Payment]
|
|
51
|
+
verify_url: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AddPaymentResult:
|
|
56
|
+
payment: Payment
|
|
57
|
+
secret: str
|
|
58
|
+
verify_url: str
|
branta/options.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from branta.enums import BrantaServerBaseUrl, PrivacyMode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class BrantaClientOptions:
|
|
11
|
+
base_url: BrantaServerBaseUrl
|
|
12
|
+
privacy: Optional[PrivacyMode] = None
|
|
13
|
+
default_api_key: Optional[str] = None
|
|
14
|
+
hmac_secret: Optional[str] = None
|
branta/v2/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from branta.v2.builder import PaymentBuilder
|
|
2
|
+
from branta.v2.client import BrantaClient
|
|
3
|
+
from branta.v2.encryption import AesEncryptionService
|
|
4
|
+
from branta.v2.parser import QRParser, QrDestination
|
|
5
|
+
from branta.v2.secret_generator import GuidSecretGenerator
|
|
6
|
+
from branta.v2.serialization import (
|
|
7
|
+
destination_from_api,
|
|
8
|
+
destination_to_api,
|
|
9
|
+
payment_from_api,
|
|
10
|
+
payment_to_api,
|
|
11
|
+
)
|
|
12
|
+
from branta.v2.service import BrantaService
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BrantaService",
|
|
16
|
+
"BrantaClient",
|
|
17
|
+
"PaymentBuilder",
|
|
18
|
+
"QRParser",
|
|
19
|
+
"QrDestination",
|
|
20
|
+
"GuidSecretGenerator",
|
|
21
|
+
"AesEncryptionService",
|
|
22
|
+
"payment_from_api",
|
|
23
|
+
"payment_to_api",
|
|
24
|
+
"destination_from_api",
|
|
25
|
+
"destination_to_api",
|
|
26
|
+
]
|
branta/v2/builder.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from branta.enums import DestinationType
|
|
8
|
+
from branta.models import Destination, Payment, Platform
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PaymentBuilder:
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._payment = Payment()
|
|
14
|
+
|
|
15
|
+
def add_destination(self, address: str, type: Optional[DestinationType] = None) -> "PaymentBuilder":
|
|
16
|
+
destination = Destination(value=address, is_zk=False)
|
|
17
|
+
if type is not None:
|
|
18
|
+
destination.type = type
|
|
19
|
+
self._payment.destinations.append(destination)
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def set_zk(self) -> "PaymentBuilder":
|
|
23
|
+
if self._payment.destinations:
|
|
24
|
+
destination = self._payment.destinations[-1]
|
|
25
|
+
destination.is_zk = True
|
|
26
|
+
destination.zk_id = str(uuid.uuid4())
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
def set_description(self, description: str) -> "PaymentBuilder":
|
|
30
|
+
self._payment.description = description
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def add_metadata(self, key: str, value: str) -> "PaymentBuilder":
|
|
34
|
+
metadata_map: dict = json.loads(self._payment.metadata) if self._payment.metadata else {}
|
|
35
|
+
metadata_map[key] = value
|
|
36
|
+
self._payment.metadata = json.dumps(metadata_map)
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def set_ttl(self, ttl: int) -> "PaymentBuilder":
|
|
40
|
+
self._payment.ttl = ttl
|
|
41
|
+
return self
|
|
42
|
+
|
|
43
|
+
def set_platform_logo_url(self, platform_logo_url: str) -> "PaymentBuilder":
|
|
44
|
+
self._payment.platform_logo_url = platform_logo_url
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
def set_child_platform(
|
|
48
|
+
self,
|
|
49
|
+
name: str,
|
|
50
|
+
logo_url: Optional[str] = None,
|
|
51
|
+
logo_light_url: Optional[str] = None,
|
|
52
|
+
) -> "PaymentBuilder":
|
|
53
|
+
self._payment.child_platform = Platform(name=name, logo_url=logo_url, logo_light_url=logo_light_url)
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def build(self) -> Payment:
|
|
57
|
+
return self._payment
|
branta/v2/client.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
from urllib.parse import urlparse, quote
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
from branta.exceptions import BrantaPaymentException
|
|
13
|
+
from branta.extensions import get_api_key, get_base_url, get_hmac_secret
|
|
14
|
+
from branta.models import Payment
|
|
15
|
+
from branta.options import BrantaClientOptions
|
|
16
|
+
from branta.v2.serialization import payment_from_api, payment_to_api
|
|
17
|
+
|
|
18
|
+
_STATUS_TEXT: dict = {
|
|
19
|
+
400: "BadRequest",
|
|
20
|
+
401: "Unauthorized",
|
|
21
|
+
403: "Forbidden",
|
|
22
|
+
404: "NotFound",
|
|
23
|
+
409: "Conflict",
|
|
24
|
+
422: "UnprocessableEntity",
|
|
25
|
+
429: "TooManyRequests",
|
|
26
|
+
500: "InternalServerError",
|
|
27
|
+
502: "BadGateway",
|
|
28
|
+
503: "ServiceUnavailable",
|
|
29
|
+
504: "GatewayTimeout",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _hmac_sha256_hex(secret: str, message: str) -> str:
|
|
34
|
+
return hmac.new(secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BrantaClient:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
default_options: Optional[BrantaClientOptions] = None,
|
|
41
|
+
session: Optional[aiohttp.ClientSession] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self._default_options = default_options
|
|
44
|
+
self._session = session
|
|
45
|
+
|
|
46
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
47
|
+
if self._session is None:
|
|
48
|
+
self._session = aiohttp.ClientSession()
|
|
49
|
+
return self._session
|
|
50
|
+
|
|
51
|
+
async def get_payments(
|
|
52
|
+
self,
|
|
53
|
+
destination_value: str,
|
|
54
|
+
options: Optional[BrantaClientOptions] = None,
|
|
55
|
+
signal: None = None,
|
|
56
|
+
) -> List[Payment]:
|
|
57
|
+
base_url = get_base_url(self._default_options, options)
|
|
58
|
+
headers = self._build_headers(options, require_api_key=False)
|
|
59
|
+
encoded = quote(destination_value, safe="")
|
|
60
|
+
url = f"{base_url}/v2/payments/{encoded}"
|
|
61
|
+
|
|
62
|
+
session = await self._get_session()
|
|
63
|
+
try:
|
|
64
|
+
async with session.get(url, headers=headers) as response:
|
|
65
|
+
if not response.ok:
|
|
66
|
+
return []
|
|
67
|
+
text = await response.text()
|
|
68
|
+
if not text:
|
|
69
|
+
return []
|
|
70
|
+
parsed = json.loads(text)
|
|
71
|
+
if not isinstance(parsed, list):
|
|
72
|
+
return []
|
|
73
|
+
payments = [payment_from_api(item) for item in parsed]
|
|
74
|
+
self._verify_logo_urls(base_url, payments)
|
|
75
|
+
return payments
|
|
76
|
+
except BrantaPaymentException:
|
|
77
|
+
raise
|
|
78
|
+
except Exception:
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
async def post_payment(
|
|
82
|
+
self,
|
|
83
|
+
payment: Payment,
|
|
84
|
+
options: Optional[BrantaClientOptions] = None,
|
|
85
|
+
signal: None = None,
|
|
86
|
+
) -> Optional[Payment]:
|
|
87
|
+
base_url = get_base_url(self._default_options, options)
|
|
88
|
+
headers = self._build_headers(options, require_api_key=True)
|
|
89
|
+
headers["Content-Type"] = "application/json"
|
|
90
|
+
body = json.dumps(payment_to_api(payment))
|
|
91
|
+
|
|
92
|
+
self._apply_hmac_headers(headers, base_url, body, options)
|
|
93
|
+
|
|
94
|
+
session = await self._get_session()
|
|
95
|
+
async with session.post(f"{base_url}/v2/payments", headers=headers, data=body) as response:
|
|
96
|
+
if not response.ok:
|
|
97
|
+
raise BrantaPaymentException(_STATUS_TEXT.get(response.status, str(response.status)))
|
|
98
|
+
text = await response.text()
|
|
99
|
+
if not text:
|
|
100
|
+
return None
|
|
101
|
+
return payment_from_api(json.loads(text))
|
|
102
|
+
|
|
103
|
+
async def is_api_key_valid(
|
|
104
|
+
self,
|
|
105
|
+
options: Optional[BrantaClientOptions] = None,
|
|
106
|
+
signal: None = None,
|
|
107
|
+
) -> bool:
|
|
108
|
+
base_url = get_base_url(self._default_options, options)
|
|
109
|
+
headers = self._build_headers(options, require_api_key=True)
|
|
110
|
+
|
|
111
|
+
session = await self._get_session()
|
|
112
|
+
try:
|
|
113
|
+
async with session.get(f"{base_url}/v2/api-keys/health-check", headers=headers) as response:
|
|
114
|
+
return response.ok
|
|
115
|
+
except Exception:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
async def close(self) -> None:
|
|
119
|
+
if self._session is not None:
|
|
120
|
+
await self._session.close()
|
|
121
|
+
self._session = None
|
|
122
|
+
|
|
123
|
+
def _build_headers(self, options: Optional[BrantaClientOptions], require_api_key: bool) -> dict:
|
|
124
|
+
headers: dict = {}
|
|
125
|
+
if require_api_key:
|
|
126
|
+
api_key = get_api_key(self._default_options, options)
|
|
127
|
+
if api_key is None:
|
|
128
|
+
raise BrantaPaymentException("Unauthorized")
|
|
129
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
130
|
+
return headers
|
|
131
|
+
|
|
132
|
+
def _apply_hmac_headers(
|
|
133
|
+
self,
|
|
134
|
+
headers: dict,
|
|
135
|
+
base_url: str,
|
|
136
|
+
body: str,
|
|
137
|
+
options: Optional[BrantaClientOptions],
|
|
138
|
+
) -> None:
|
|
139
|
+
hmac_secret = get_hmac_secret(self._default_options, options)
|
|
140
|
+
if hmac_secret is None:
|
|
141
|
+
return
|
|
142
|
+
timestamp = str(int(time.time()))
|
|
143
|
+
trimmed_base = base_url.rstrip("/")
|
|
144
|
+
message = f"POST|{trimmed_base}/v2/payments|{body}|{timestamp}"
|
|
145
|
+
signature = _hmac_sha256_hex(hmac_secret, message)
|
|
146
|
+
headers["X-HMAC-Signature"] = signature
|
|
147
|
+
headers["X-HMAC-Timestamp"] = timestamp
|
|
148
|
+
|
|
149
|
+
def _verify_logo_urls(self, base_url: str, payments: List[Payment]) -> None:
|
|
150
|
+
try:
|
|
151
|
+
base_origin = f"{urlparse(base_url).scheme}://{urlparse(base_url).netloc}"
|
|
152
|
+
except Exception:
|
|
153
|
+
return
|
|
154
|
+
for payment in payments:
|
|
155
|
+
logo_url = payment.platform_logo_url
|
|
156
|
+
if not logo_url:
|
|
157
|
+
return
|
|
158
|
+
try:
|
|
159
|
+
logo_origin = f"{urlparse(logo_url).scheme}://{urlparse(logo_url).netloc}"
|
|
160
|
+
except Exception:
|
|
161
|
+
raise BrantaPaymentException("platformLogoUrl domain does not match the configured baseUrl domain")
|
|
162
|
+
if logo_origin != base_origin:
|
|
163
|
+
raise BrantaPaymentException("platformLogoUrl domain does not match the configured baseUrl domain")
|
branta/v2/encryption.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
9
|
+
|
|
10
|
+
_NONCE_LENGTH = 12
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AesEncryption:
|
|
14
|
+
@staticmethod
|
|
15
|
+
async def encrypt(value: str, secret: str, deterministic_nonce: bool = False) -> str:
|
|
16
|
+
try:
|
|
17
|
+
key = hashlib.sha256(secret.encode("utf-8")).digest()
|
|
18
|
+
|
|
19
|
+
if deterministic_nonce:
|
|
20
|
+
nonce = hmac.new(key, value.encode("utf-8"), hashlib.sha256).digest()[:_NONCE_LENGTH]
|
|
21
|
+
else:
|
|
22
|
+
nonce = os.urandom(_NONCE_LENGTH)
|
|
23
|
+
|
|
24
|
+
aesgcm = AESGCM(key)
|
|
25
|
+
ciphertext_and_tag = aesgcm.encrypt(nonce, value.encode("utf-8"), None)
|
|
26
|
+
result = nonce + ciphertext_and_tag
|
|
27
|
+
return base64.b64encode(result).decode("utf-8")
|
|
28
|
+
except Exception as e:
|
|
29
|
+
raise Exception(f"Encryption failed: {e}") from e
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
async def decrypt(encrypted_value: str, secret: str) -> str:
|
|
33
|
+
try:
|
|
34
|
+
encrypted_data = base64.b64decode(encrypted_value)
|
|
35
|
+
except Exception as e:
|
|
36
|
+
raise Exception(f"Decryption failed: {e}") from e
|
|
37
|
+
|
|
38
|
+
if len(encrypted_data) < 28:
|
|
39
|
+
raise Exception("Invalid encrypted data: too short")
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
key = hashlib.sha256(secret.encode("utf-8")).digest()
|
|
43
|
+
nonce = encrypted_data[:_NONCE_LENGTH]
|
|
44
|
+
ciphertext_and_tag = encrypted_data[_NONCE_LENGTH:]
|
|
45
|
+
|
|
46
|
+
aesgcm = AESGCM(key)
|
|
47
|
+
plaintext = aesgcm.decrypt(nonce, ciphertext_and_tag, None)
|
|
48
|
+
return plaintext.decode("utf-8")
|
|
49
|
+
except Exception as e:
|
|
50
|
+
raise Exception(f"Decryption failed: {e}") from e
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AesEncryptionService:
|
|
54
|
+
async def encrypt(self, value: str, secret: str, deterministic_nonce: bool = False) -> str:
|
|
55
|
+
return await AesEncryption.encrypt(value, secret, deterministic_nonce)
|
|
56
|
+
|
|
57
|
+
async def decrypt(self, encrypted_value: str, secret: str) -> str:
|
|
58
|
+
return await AesEncryption.decrypt(encrypted_value, secret)
|
branta/v2/parser.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
6
|
+
|
|
7
|
+
from branta.enums import DestinationType
|
|
8
|
+
from branta.extensions import is_bolt11, is_silent_payment
|
|
9
|
+
|
|
10
|
+
_LN_ADDRESS_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
|
11
|
+
_ETH_HEX_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_ethereum_address(value: str) -> bool:
|
|
15
|
+
return len(value) == 42 and value.lower().startswith("0x") and bool(_ETH_HEX_RE.match(value[2:]))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_tron_address(value: str) -> bool:
|
|
19
|
+
return len(value) == 34 and value.startswith("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _detect_plain_text_type(value: str) -> Optional[DestinationType]:
|
|
23
|
+
if is_bolt11(value):
|
|
24
|
+
return DestinationType.Bolt11
|
|
25
|
+
lower = value.lower()
|
|
26
|
+
if lower.startswith("lno"):
|
|
27
|
+
return DestinationType.Bolt12
|
|
28
|
+
if lower.startswith("lnurl"):
|
|
29
|
+
return DestinationType.LnUrl
|
|
30
|
+
if lower.startswith("ark1"):
|
|
31
|
+
return DestinationType.ArkAddress
|
|
32
|
+
if is_silent_payment(value):
|
|
33
|
+
return DestinationType.SilentPayment
|
|
34
|
+
if _is_ethereum_address(value):
|
|
35
|
+
return DestinationType.TetherAddress
|
|
36
|
+
if _is_tron_address(value):
|
|
37
|
+
return DestinationType.TetherAddress
|
|
38
|
+
if _LN_ADDRESS_RE.match(value):
|
|
39
|
+
return DestinationType.LnAddress
|
|
40
|
+
if value.startswith("1") or value.startswith("3") or lower.startswith("bc1"):
|
|
41
|
+
return DestinationType.BitcoinAddress
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_destination_type(text: str) -> Optional[DestinationType]:
|
|
46
|
+
lower = text.lower()
|
|
47
|
+
if lower.startswith("bitcoin:"):
|
|
48
|
+
return DestinationType.BitcoinAddress
|
|
49
|
+
if lower.startswith("lightning:"):
|
|
50
|
+
dest = _extract_path(text)
|
|
51
|
+
if dest:
|
|
52
|
+
if is_bolt11(dest):
|
|
53
|
+
return DestinationType.Bolt11
|
|
54
|
+
if dest.lower().startswith("lno"):
|
|
55
|
+
return DestinationType.Bolt12
|
|
56
|
+
if dest.lower().startswith("lnurl"):
|
|
57
|
+
return DestinationType.LnUrl
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _extract_path(text: str) -> Optional[str]:
|
|
62
|
+
colon_idx = text.find(":")
|
|
63
|
+
if colon_idx == -1:
|
|
64
|
+
return None
|
|
65
|
+
after_colon = text[colon_idx + 1:]
|
|
66
|
+
question_idx = after_colon.find("?")
|
|
67
|
+
if question_idx == -1:
|
|
68
|
+
return after_colon if after_colon else None
|
|
69
|
+
return after_colon[:question_idx] if after_colon[:question_idx] else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_query_string(query: str) -> Dict[str, str]:
|
|
73
|
+
result: Dict[str, str] = {}
|
|
74
|
+
trimmed = query.lstrip("?")
|
|
75
|
+
if not trimmed:
|
|
76
|
+
return result
|
|
77
|
+
for part in trimmed.split("&"):
|
|
78
|
+
if not part:
|
|
79
|
+
continue
|
|
80
|
+
idx = part.find("=")
|
|
81
|
+
if idx == -1:
|
|
82
|
+
raw_key, raw_value = part, ""
|
|
83
|
+
else:
|
|
84
|
+
raw_key, raw_value = part[:idx], part[idx + 1:]
|
|
85
|
+
if not raw_key:
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
key = unquote(raw_key).lower()
|
|
89
|
+
value = unquote(raw_value)
|
|
90
|
+
if key not in result:
|
|
91
|
+
result[key] = value
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class QrDestination:
|
|
98
|
+
def __init__(self, value: str, type: Optional[DestinationType] = None) -> None:
|
|
99
|
+
self.value = value
|
|
100
|
+
self.type = type
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class QRParser:
|
|
104
|
+
def __init__(self, qr_text: str) -> None:
|
|
105
|
+
self.destinations: List[QrDestination] = []
|
|
106
|
+
self.on_chain_encryption_text: Optional[str] = None
|
|
107
|
+
self.on_chain_encryption_secret: Optional[str] = None
|
|
108
|
+
|
|
109
|
+
text = qr_text.strip()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
parsed = urlparse(text)
|
|
113
|
+
scheme = parsed.scheme.lower()
|
|
114
|
+
except Exception:
|
|
115
|
+
self.destinations.append(QrDestination(value=text, type=_detect_plain_text_type(text)))
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if not parsed.scheme:
|
|
119
|
+
self.destinations.append(QrDestination(value=text, type=_detect_plain_text_type(text)))
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
if scheme in ("bitcoin", "lightning"):
|
|
123
|
+
dest = _extract_path(text)
|
|
124
|
+
if dest is not None:
|
|
125
|
+
self.destinations.append(QrDestination(value=dest, type=_get_destination_type(text)))
|
|
126
|
+
|
|
127
|
+
query = parsed.query
|
|
128
|
+
params = _parse_query_string(query)
|
|
129
|
+
|
|
130
|
+
self.on_chain_encryption_text = params.get("branta_id")
|
|
131
|
+
self.on_chain_encryption_secret = params.get("branta_secret")
|
|
132
|
+
|
|
133
|
+
lightning_value = params.get("lightning")
|
|
134
|
+
if lightning_value is not None:
|
|
135
|
+
self.destinations.append(QrDestination(value=lightning_value, type=_detect_plain_text_type(lightning_value)))
|
|
136
|
+
|
|
137
|
+
bolt12_value = params.get("bolt12")
|
|
138
|
+
if bolt12_value is not None:
|
|
139
|
+
self.destinations.append(QrDestination(value=bolt12_value, type=_detect_plain_text_type(bolt12_value)))
|
|
140
|
+
|
|
141
|
+
ark_value = params.get("ark")
|
|
142
|
+
if ark_value is not None:
|
|
143
|
+
self.destinations.append(QrDestination(value=ark_value, type=_detect_plain_text_type(ark_value)))
|
|
144
|
+
|
|
145
|
+
silent_payment_value = params.get("silent_payment")
|
|
146
|
+
if silent_payment_value is not None:
|
|
147
|
+
self.destinations.append(QrDestination(value=silent_payment_value, type=_detect_plain_text_type(silent_payment_value)))
|
|
148
|
+
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
self.destinations.append(QrDestination(value=text))
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def destination(self) -> Optional[str]:
|
|
155
|
+
return self.destinations[0].value if self.destinations else None
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def destination_type(self) -> Optional[DestinationType]:
|
|
159
|
+
return self.destinations[0].type if self.destinations else None
|
|
160
|
+
|
|
161
|
+
def is_on_chain_zk(self) -> bool:
|
|
162
|
+
return self.on_chain_encryption_text is not None and self.on_chain_encryption_secret is not None
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
from branta.enums import DestinationType
|
|
6
|
+
from branta.models import Destination, Payment, Platform
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def destination_to_api(destination: Destination) -> Dict[str, Any]:
|
|
10
|
+
result: Dict[str, Any] = {
|
|
11
|
+
"value": destination.value,
|
|
12
|
+
"primary": destination.is_primary,
|
|
13
|
+
"zk": destination.is_zk,
|
|
14
|
+
}
|
|
15
|
+
if destination.type is not None:
|
|
16
|
+
result["type"] = destination.type.value
|
|
17
|
+
if destination.zk_id is not None:
|
|
18
|
+
result["zk_id"] = destination.zk_id
|
|
19
|
+
if destination.encrypted_dek is not None:
|
|
20
|
+
result["encrypted_dek"] = destination.encrypted_dek
|
|
21
|
+
return result
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def destination_from_api(raw: Dict[str, Any]) -> Destination:
|
|
25
|
+
destination = Destination(
|
|
26
|
+
value=str(raw.get("value", "")),
|
|
27
|
+
is_primary=bool(raw.get("primary", False)),
|
|
28
|
+
is_zk=bool(raw.get("zk", False)),
|
|
29
|
+
)
|
|
30
|
+
raw_type = raw.get("type")
|
|
31
|
+
if raw_type is not None:
|
|
32
|
+
try:
|
|
33
|
+
destination.type = DestinationType(raw_type)
|
|
34
|
+
except ValueError:
|
|
35
|
+
pass
|
|
36
|
+
raw_zk_id = raw.get("zk_id")
|
|
37
|
+
if raw_zk_id is not None:
|
|
38
|
+
destination.zk_id = str(raw_zk_id)
|
|
39
|
+
raw_encrypted_dek = raw.get("encrypted_dek")
|
|
40
|
+
if raw_encrypted_dek is not None:
|
|
41
|
+
destination.encrypted_dek = str(raw_encrypted_dek)
|
|
42
|
+
return destination
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def payment_to_api(payment: Payment) -> Dict[str, Any]:
|
|
46
|
+
result: Dict[str, Any] = {
|
|
47
|
+
"destinations": [destination_to_api(d) for d in payment.destinations],
|
|
48
|
+
}
|
|
49
|
+
if payment.description is not None:
|
|
50
|
+
result["description"] = payment.description
|
|
51
|
+
if payment.created_date is not None:
|
|
52
|
+
result["created_at"] = payment.created_date
|
|
53
|
+
if payment.ttl is not None:
|
|
54
|
+
result["ttl"] = payment.ttl
|
|
55
|
+
if payment.metadata is not None:
|
|
56
|
+
result["metadata"] = payment.metadata
|
|
57
|
+
if payment.platform is not None:
|
|
58
|
+
result["platform"] = payment.platform
|
|
59
|
+
if payment.platform_logo_url is not None:
|
|
60
|
+
result["platform_logo_url"] = payment.platform_logo_url
|
|
61
|
+
if payment.platform_logo_light_url is not None:
|
|
62
|
+
result["platform_logo_light_url"] = payment.platform_logo_light_url
|
|
63
|
+
if payment.child_platform is not None:
|
|
64
|
+
cp: Dict[str, Any] = {}
|
|
65
|
+
if payment.child_platform.name is not None:
|
|
66
|
+
cp["name"] = payment.child_platform.name
|
|
67
|
+
if payment.child_platform.logo_url is not None:
|
|
68
|
+
cp["logo_url"] = payment.child_platform.logo_url
|
|
69
|
+
if payment.child_platform.logo_light_url is not None:
|
|
70
|
+
cp["logo_light_url"] = payment.child_platform.logo_light_url
|
|
71
|
+
result["child_platform"] = cp
|
|
72
|
+
if payment.btc_pay_server_plugin_version is not None:
|
|
73
|
+
result["btc_pay_server_plugin_version"] = payment.btc_pay_server_plugin_version
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def payment_from_api(raw: Dict[str, Any]) -> Payment:
|
|
78
|
+
destinations_raw = raw.get("destinations", [])
|
|
79
|
+
if not isinstance(destinations_raw, list):
|
|
80
|
+
destinations_raw = []
|
|
81
|
+
payment = Payment(destinations=[destination_from_api(d) for d in destinations_raw])
|
|
82
|
+
if raw.get("description") is not None:
|
|
83
|
+
payment.description = str(raw["description"])
|
|
84
|
+
if raw.get("created_at") is not None:
|
|
85
|
+
payment.created_date = str(raw["created_at"])
|
|
86
|
+
if raw.get("ttl") is not None:
|
|
87
|
+
payment.ttl = int(raw["ttl"])
|
|
88
|
+
if raw.get("metadata") is not None:
|
|
89
|
+
payment.metadata = str(raw["metadata"])
|
|
90
|
+
if raw.get("platform") is not None:
|
|
91
|
+
payment.platform = str(raw["platform"])
|
|
92
|
+
if raw.get("platform_logo_url") is not None:
|
|
93
|
+
payment.platform_logo_url = str(raw["platform_logo_url"])
|
|
94
|
+
if raw.get("platform_logo_light_url") is not None:
|
|
95
|
+
payment.platform_logo_light_url = str(raw["platform_logo_light_url"])
|
|
96
|
+
if raw.get("parent_platform") is not None:
|
|
97
|
+
pp = raw["parent_platform"]
|
|
98
|
+
if isinstance(pp, dict):
|
|
99
|
+
parent = Platform()
|
|
100
|
+
if pp.get("name") is not None:
|
|
101
|
+
parent.name = str(pp["name"])
|
|
102
|
+
if pp.get("logo_url") is not None:
|
|
103
|
+
parent.logo_url = str(pp["logo_url"])
|
|
104
|
+
if pp.get("logo_light_url") is not None:
|
|
105
|
+
parent.logo_light_url = str(pp["logo_light_url"])
|
|
106
|
+
payment.parent_platform = parent
|
|
107
|
+
if raw.get("btc_pay_server_plugin_version") is not None:
|
|
108
|
+
payment.btc_pay_server_plugin_version = str(raw["btc_pay_server_plugin_version"])
|
|
109
|
+
return payment
|
branta/v2/service.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from branta.enums import DestinationType, PrivacyMode
|
|
7
|
+
from branta.exceptions import BrantaPaymentException
|
|
8
|
+
from branta.extensions import (
|
|
9
|
+
get_base_url,
|
|
10
|
+
get_hash_zk_type,
|
|
11
|
+
get_privacy,
|
|
12
|
+
to_normalized_hash,
|
|
13
|
+
to_url_fragment,
|
|
14
|
+
)
|
|
15
|
+
from branta.models import AddPaymentResult, Destination, Payment, PaymentsResult
|
|
16
|
+
from branta.options import BrantaClientOptions
|
|
17
|
+
from branta.v2.builder import PaymentBuilder
|
|
18
|
+
from branta.v2.client import BrantaClient
|
|
19
|
+
from branta.v2.encryption import AesEncryptionService
|
|
20
|
+
from branta.v2.parser import QRParser
|
|
21
|
+
from branta.v2.secret_generator import GuidSecretGenerator
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BrantaService:
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
default_options: Optional[BrantaClientOptions] = None,
|
|
28
|
+
*,
|
|
29
|
+
client: Optional[object] = None,
|
|
30
|
+
aes_encryption: Optional[object] = None,
|
|
31
|
+
secret_generator: Optional[object] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
self._default_options = default_options
|
|
34
|
+
self._client = client if client is not None else BrantaClient(default_options)
|
|
35
|
+
self._aes_encryption = aes_encryption if aes_encryption is not None else AesEncryptionService()
|
|
36
|
+
self._secret_generator = secret_generator if secret_generator is not None else GuidSecretGenerator()
|
|
37
|
+
|
|
38
|
+
def create_payment_builder(self) -> PaymentBuilder:
|
|
39
|
+
return PaymentBuilder()
|
|
40
|
+
|
|
41
|
+
async def get_payments_by_qr_code(
|
|
42
|
+
self,
|
|
43
|
+
qr_text: str,
|
|
44
|
+
options: Optional[BrantaClientOptions] = None,
|
|
45
|
+
) -> PaymentsResult:
|
|
46
|
+
parser = QRParser(qr_text)
|
|
47
|
+
|
|
48
|
+
if parser.is_on_chain_zk():
|
|
49
|
+
additional_values = [
|
|
50
|
+
d.value
|
|
51
|
+
for d in parser.destinations
|
|
52
|
+
if get_hash_zk_type(d.value) is not None
|
|
53
|
+
]
|
|
54
|
+
return await self._get_payments_for_zk(
|
|
55
|
+
parser.on_chain_encryption_text,
|
|
56
|
+
parser.on_chain_encryption_secret,
|
|
57
|
+
additional_values,
|
|
58
|
+
options,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
destination = parser.destination
|
|
62
|
+
if destination is None:
|
|
63
|
+
return PaymentsResult(payments=[], verify_url=self._build_verify_url(options, ""))
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
get_privacy(self._default_options, options) == PrivacyMode.Strict
|
|
67
|
+
and get_hash_zk_type(destination) is None
|
|
68
|
+
):
|
|
69
|
+
return PaymentsResult(payments=[], verify_url=self._build_verify_url(options, destination))
|
|
70
|
+
|
|
71
|
+
return await self.get_payments(destination, None, options)
|
|
72
|
+
|
|
73
|
+
async def get_payments(
|
|
74
|
+
self,
|
|
75
|
+
destination_value: str,
|
|
76
|
+
destination_encryption_key: Optional[str] = None,
|
|
77
|
+
options: Optional[BrantaClientOptions] = None,
|
|
78
|
+
) -> PaymentsResult:
|
|
79
|
+
hash_zk_type = get_hash_zk_type(destination_value)
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
hash_zk_type is None
|
|
83
|
+
and destination_encryption_key is None
|
|
84
|
+
and get_privacy(self._default_options, options) == PrivacyMode.Strict
|
|
85
|
+
):
|
|
86
|
+
raise BrantaPaymentException(
|
|
87
|
+
"PrivacyMode.Strict does not permit plain-text lookups for this destination type."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
normalized_destination = destination_value.lower() if hash_zk_type is not None else destination_value
|
|
91
|
+
lookup_value = normalized_destination
|
|
92
|
+
|
|
93
|
+
if hash_zk_type is not None:
|
|
94
|
+
lookup_value = await self._aes_encryption.encrypt(
|
|
95
|
+
normalized_destination,
|
|
96
|
+
to_normalized_hash(normalized_destination),
|
|
97
|
+
True,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
payments = await self._client.get_payments(lookup_value, options)
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
len(payments) == 0
|
|
104
|
+
and hash_zk_type is not None
|
|
105
|
+
and get_privacy(self._default_options, options) != PrivacyMode.Strict
|
|
106
|
+
):
|
|
107
|
+
lookup_value = normalized_destination
|
|
108
|
+
payments = await self._client.get_payments(lookup_value, options)
|
|
109
|
+
|
|
110
|
+
keys: Dict[str, str] = {}
|
|
111
|
+
for payment in payments:
|
|
112
|
+
await self._decrypt_destinations(payment, normalized_destination, destination_encryption_key, hash_zk_type, keys)
|
|
113
|
+
|
|
114
|
+
return PaymentsResult(payments=payments, verify_url=self._build_verify_url(options, lookup_value, keys))
|
|
115
|
+
|
|
116
|
+
async def add_payment(
|
|
117
|
+
self,
|
|
118
|
+
payment: Payment,
|
|
119
|
+
options: Optional[BrantaClientOptions] = None,
|
|
120
|
+
) -> AddPaymentResult:
|
|
121
|
+
if (
|
|
122
|
+
get_privacy(self._default_options, options) == PrivacyMode.Strict
|
|
123
|
+
and any(not d.is_zk for d in payment.destinations)
|
|
124
|
+
):
|
|
125
|
+
raise BrantaPaymentException(
|
|
126
|
+
"PrivacyMode.Strict requires all destinations to be ZK; one or more destinations have is_zk = False."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
dek: Optional[str] = None
|
|
130
|
+
if payment.metadata is not None and any(d.is_zk for d in payment.destinations):
|
|
131
|
+
dek = self._secret_generator.generate()
|
|
132
|
+
payment.metadata = await self._aes_encryption.encrypt(payment.metadata, dek, False)
|
|
133
|
+
|
|
134
|
+
secret = self._secret_generator.generate()
|
|
135
|
+
encrypted_to_key: Dict[str, str] = {}
|
|
136
|
+
|
|
137
|
+
for destination in payment.destinations:
|
|
138
|
+
if not destination.is_zk:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if destination.type == DestinationType.BitcoinAddress:
|
|
142
|
+
destination.value = await self._aes_encryption.encrypt(
|
|
143
|
+
destination.value, secret, self._secret_generator.deterministic_nonce
|
|
144
|
+
)
|
|
145
|
+
encrypted_to_key[destination.value] = secret
|
|
146
|
+
if dek is not None:
|
|
147
|
+
destination.encrypted_dek = await self._aes_encryption.encrypt(dek, secret, False)
|
|
148
|
+
else:
|
|
149
|
+
hash_zk_type = get_hash_zk_type(destination.value)
|
|
150
|
+
if hash_zk_type is None:
|
|
151
|
+
raise BrantaPaymentException(
|
|
152
|
+
f"destination type '{destination.type}' does not support ZK"
|
|
153
|
+
)
|
|
154
|
+
normalized_value = destination.value.lower()
|
|
155
|
+
key = to_normalized_hash(normalized_value)
|
|
156
|
+
destination.value = await self._aes_encryption.encrypt(normalized_value, key, True)
|
|
157
|
+
encrypted_to_key[destination.value] = key
|
|
158
|
+
if dek is not None:
|
|
159
|
+
destination.encrypted_dek = await self._aes_encryption.encrypt(dek, key, False)
|
|
160
|
+
|
|
161
|
+
response_payment = await self._client.post_payment(payment, options)
|
|
162
|
+
if response_payment is None:
|
|
163
|
+
raise BrantaPaymentException("No payment returned from server.")
|
|
164
|
+
|
|
165
|
+
keys: Dict[str, str] = {}
|
|
166
|
+
for d in response_payment.destinations:
|
|
167
|
+
if d.zk_id is not None and d.value in encrypted_to_key:
|
|
168
|
+
keys[d.zk_id] = encrypted_to_key[d.value]
|
|
169
|
+
|
|
170
|
+
primary_value = payment.destinations[0].value if payment.destinations else ""
|
|
171
|
+
verify_url = self._build_verify_url(options, primary_value, keys)
|
|
172
|
+
|
|
173
|
+
return AddPaymentResult(payment=response_payment, secret=secret, verify_url=verify_url)
|
|
174
|
+
|
|
175
|
+
async def is_api_key_valid(self, options: Optional[BrantaClientOptions] = None) -> bool:
|
|
176
|
+
return await self._client.is_api_key_valid(options)
|
|
177
|
+
|
|
178
|
+
async def close(self) -> None:
|
|
179
|
+
if hasattr(self._client, "close"):
|
|
180
|
+
await self._client.close()
|
|
181
|
+
|
|
182
|
+
async def __aenter__(self) -> "BrantaService":
|
|
183
|
+
return self
|
|
184
|
+
|
|
185
|
+
async def __aexit__(self, *args: object) -> None:
|
|
186
|
+
await self.close()
|
|
187
|
+
|
|
188
|
+
# ---- private helpers ----
|
|
189
|
+
|
|
190
|
+
async def _get_payments_for_zk(
|
|
191
|
+
self,
|
|
192
|
+
lookup_value: str,
|
|
193
|
+
encryption_key: Optional[str],
|
|
194
|
+
additional_hash_values: List[str],
|
|
195
|
+
options: Optional[BrantaClientOptions],
|
|
196
|
+
) -> PaymentsResult:
|
|
197
|
+
payments = await self._client.get_payments(lookup_value, options)
|
|
198
|
+
|
|
199
|
+
keys: Dict[str, str] = {}
|
|
200
|
+
for payment in payments:
|
|
201
|
+
await self._decrypt_destinations(payment, lookup_value, encryption_key, None, keys)
|
|
202
|
+
for value in additional_hash_values:
|
|
203
|
+
await self._decrypt_hash_zk_destinations(payment, value, keys)
|
|
204
|
+
|
|
205
|
+
return PaymentsResult(payments=payments, verify_url=self._build_verify_url(options, lookup_value, keys))
|
|
206
|
+
|
|
207
|
+
async def _decrypt_hash_zk_destinations(
|
|
208
|
+
self,
|
|
209
|
+
payment: Payment,
|
|
210
|
+
plain_value: str,
|
|
211
|
+
keys: Dict[str, str],
|
|
212
|
+
) -> None:
|
|
213
|
+
hash_zk_type = get_hash_zk_type(plain_value)
|
|
214
|
+
if hash_zk_type is None:
|
|
215
|
+
return
|
|
216
|
+
key = to_normalized_hash(plain_value)
|
|
217
|
+
for destination in payment.destinations:
|
|
218
|
+
if not destination.is_zk or destination.type != hash_zk_type:
|
|
219
|
+
continue
|
|
220
|
+
try:
|
|
221
|
+
destination.value = await self._aes_encryption.decrypt(destination.value, key)
|
|
222
|
+
destination.is_encrypted = False
|
|
223
|
+
if destination.zk_id is not None and destination.zk_id not in keys:
|
|
224
|
+
keys[destination.zk_id] = key
|
|
225
|
+
await self._try_decrypt_metadata(payment, destination, key)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
async def _decrypt_destinations(
|
|
230
|
+
self,
|
|
231
|
+
payment: Payment,
|
|
232
|
+
destination_value: str,
|
|
233
|
+
encryption_key: Optional[str],
|
|
234
|
+
hash_zk_type: Optional[DestinationType],
|
|
235
|
+
keys: Dict[str, str],
|
|
236
|
+
) -> None:
|
|
237
|
+
for destination in payment.destinations:
|
|
238
|
+
destination.is_encrypted = bool(destination.is_zk)
|
|
239
|
+
if not destination.is_zk:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
if destination.type == DestinationType.BitcoinAddress:
|
|
243
|
+
if encryption_key is None:
|
|
244
|
+
continue
|
|
245
|
+
try:
|
|
246
|
+
destination.value = await self._aes_encryption.decrypt(destination.value, encryption_key)
|
|
247
|
+
destination.is_encrypted = False
|
|
248
|
+
if destination.zk_id is not None and destination.zk_id not in keys:
|
|
249
|
+
keys[destination.zk_id] = encryption_key
|
|
250
|
+
await self._try_decrypt_metadata(payment, destination, encryption_key)
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
elif hash_zk_type is not None and destination.type == hash_zk_type:
|
|
254
|
+
key = to_normalized_hash(destination_value)
|
|
255
|
+
try:
|
|
256
|
+
destination.value = await self._aes_encryption.decrypt(destination.value, key)
|
|
257
|
+
destination.is_encrypted = False
|
|
258
|
+
if destination.zk_id is not None and destination.zk_id not in keys:
|
|
259
|
+
keys[destination.zk_id] = key
|
|
260
|
+
await self._try_decrypt_metadata(payment, destination, key)
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
async def _try_decrypt_metadata(
|
|
265
|
+
self,
|
|
266
|
+
payment: Payment,
|
|
267
|
+
destination: Destination,
|
|
268
|
+
key_used: str,
|
|
269
|
+
) -> None:
|
|
270
|
+
if destination.encrypted_dek is None or payment.metadata is None or payment.is_metadata_decrypted:
|
|
271
|
+
return
|
|
272
|
+
try:
|
|
273
|
+
dek = await self._aes_encryption.decrypt(destination.encrypted_dek, key_used)
|
|
274
|
+
payment.metadata = await self._aes_encryption.decrypt(payment.metadata, dek)
|
|
275
|
+
payment.is_metadata_decrypted = True
|
|
276
|
+
except Exception:
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
def _build_verify_url(
|
|
280
|
+
self,
|
|
281
|
+
options: Optional[BrantaClientOptions],
|
|
282
|
+
payment_lookup: str,
|
|
283
|
+
keys: Optional[Dict[str, str]] = None,
|
|
284
|
+
) -> str:
|
|
285
|
+
base_url = get_base_url(self._default_options, options)
|
|
286
|
+
encoded = quote(payment_lookup, safe="")
|
|
287
|
+
url = f"{base_url}/v2/verify/{encoded}"
|
|
288
|
+
if keys:
|
|
289
|
+
url += to_url_fragment(keys)
|
|
290
|
+
return url
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: branta
|
|
3
|
+
Version: 3.2.0
|
|
4
|
+
Summary: Python SDK for the Branta V2 API — payment destination lookup and registration with zero-knowledge encryption support
|
|
5
|
+
Project-URL: Homepage, https://branta.pro
|
|
6
|
+
Project-URL: Repository, https://github.com/BrantaOps/branta-python
|
|
7
|
+
Project-URL: Changelog, https://github.com/BrantaOps/branta-python/blob/main/CHANGELOG.md
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: bitcoin,branta,crypto,lightning,payments
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
22
|
+
Requires-Dist: cryptography>=42.0.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: coverage>=7.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Branta Python SDK
|
|
30
|
+
|
|
31
|
+
Python SDK for the [Branta](https://branta.pro) V2 API — payment destination lookup and registration with zero-knowledge encryption support.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install branta
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Integration Guide
|
|
40
|
+
|
|
41
|
+
### Quick start
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import asyncio
|
|
45
|
+
from branta.enums import BrantaServerBaseUrl, PrivacyMode
|
|
46
|
+
from branta.options import BrantaClientOptions
|
|
47
|
+
from branta.v2 import BrantaService
|
|
48
|
+
|
|
49
|
+
options = BrantaClientOptions(
|
|
50
|
+
base_url=BrantaServerBaseUrl.Production,
|
|
51
|
+
privacy=PrivacyMode.Strict,
|
|
52
|
+
)
|
|
53
|
+
service = BrantaService(options)
|
|
54
|
+
|
|
55
|
+
async def main():
|
|
56
|
+
result = await service.get_payments_by_qr_code("bitcoin:bc1q...")
|
|
57
|
+
if result.payments:
|
|
58
|
+
print(result.payments[0].get_default_value())
|
|
59
|
+
|
|
60
|
+
asyncio.run(main())
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Privacy modes
|
|
64
|
+
|
|
65
|
+
| Mode | Behaviour |
|
|
66
|
+
|------|-----------|
|
|
67
|
+
| `PrivacyMode.Strict` (default) | Only ZK lookups. `get_payments()` raises `BrantaPaymentException` for plain addresses. `get_payments_by_qr_code()` returns empty for plain addresses. `add_payment()` raises if any destination has `is_zk=False`. |
|
|
68
|
+
| `PrivacyMode.Loose` | Both plain and ZK lookups are permitted. |
|
|
69
|
+
|
|
70
|
+
### Looking up a payment by QR code
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
result = await service.get_payments_by_qr_code(qr_text)
|
|
74
|
+
# result.payments — list of Payment objects (empty if not found)
|
|
75
|
+
# result.verify_url — always populated; share with the payer to verify
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Prefer `get_payments_by_qr_code` for QR-driven flows. It handles multi-destination payloads (`branta_id` / `branta_secret` fragments) automatically.
|
|
79
|
+
|
|
80
|
+
### Looking up a payment by destination value
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# Plain bitcoin address (requires PrivacyMode.Loose or will raise):
|
|
84
|
+
result = await service.get_payments("bc1q...")
|
|
85
|
+
|
|
86
|
+
# ZK-encrypted bitcoin address with secret:
|
|
87
|
+
result = await service.get_payments(encrypted_address, destination_encryption_key="my-secret")
|
|
88
|
+
|
|
89
|
+
# BOLT-11 invoice (hash-ZK — works in strict mode):
|
|
90
|
+
result = await service.get_payments("lnbc...")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Registering a payment
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from branta.enums import DestinationType
|
|
97
|
+
|
|
98
|
+
builder = service.create_payment_builder()
|
|
99
|
+
payment = (
|
|
100
|
+
builder
|
|
101
|
+
.add_destination("bc1q...", DestinationType.BitcoinAddress).set_zk()
|
|
102
|
+
.add_destination("lnbc...", DestinationType.Bolt11).set_zk()
|
|
103
|
+
.set_description("Donation")
|
|
104
|
+
.add_metadata("email", "donor@example.com")
|
|
105
|
+
.build()
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
result = await service.add_payment(payment, BrantaClientOptions(
|
|
109
|
+
base_url=BrantaServerBaseUrl.Production,
|
|
110
|
+
default_api_key="your-api-key",
|
|
111
|
+
privacy=PrivacyMode.Strict,
|
|
112
|
+
))
|
|
113
|
+
# result.payment — the registered payment from the server
|
|
114
|
+
# result.secret — the random encryption key for the bitcoin address
|
|
115
|
+
# result.verify_url — share this URL to verify the payment
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Validating an API key
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
is_valid = await service.is_api_key_valid(BrantaClientOptions(
|
|
122
|
+
base_url=BrantaServerBaseUrl.Production,
|
|
123
|
+
default_api_key="your-api-key",
|
|
124
|
+
))
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Per-call option overrides
|
|
128
|
+
|
|
129
|
+
Every public method accepts an optional `BrantaClientOptions` parameter that overrides the service's default options for that call only:
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
service = BrantaService(default_options)
|
|
133
|
+
result = await service.get_payments("lnbc...", options=override_options)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## ZK destination types
|
|
137
|
+
|
|
138
|
+
| Type | Encryption |
|
|
139
|
+
|------|-----------|
|
|
140
|
+
| `BitcoinAddress` | Random secret (GUID) per payment |
|
|
141
|
+
| `Bolt11` | Deterministic: SHA256 of lowercase invoice |
|
|
142
|
+
| `ArkAddress` | Deterministic: SHA256 of lowercase address |
|
|
143
|
+
| `SilentPayment` | Deterministic: SHA256 of lowercase address |
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
pip install -e ".[dev]"
|
|
149
|
+
pytest tests/ --ignore=tests/test_integration.py # unit tests
|
|
150
|
+
pytest tests/test_integration.py # integration (requires network)
|
|
151
|
+
coverage run -m pytest tests/ --ignore=tests/test_integration.py && coverage report
|
|
152
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
branta/__init__.py,sha256=ot4GtxAnzxFbLOBX6yxgRUqgdQALamDYgqtCX07cdLs,1125
|
|
2
|
+
branta/enums.py,sha256=4DNf5NPlmYxVajbJtnnhaAFSYvAEkt6uGBAIeI90lGY,551
|
|
3
|
+
branta/exceptions.py,sha256=U6m_71esZOWo9KGVcqW03kUz16HVJ9sbNG6jsm1RydQ,96
|
|
4
|
+
branta/extensions.py,sha256=UDpS1GZSKVKDeC9xr2-q_sTfSFSn9GyKsXOyxBwWbJM,3089
|
|
5
|
+
branta/models.py,sha256=5S413Gs_IxUZZxMDyy-eJnOw5cjEXV6hHGWlwYOzogE,1461
|
|
6
|
+
branta/options.py,sha256=SKLhrQdecTeTwtlpJEdaZNhFnkMdg_jmrjSTcd9VGbw,353
|
|
7
|
+
branta/v2/__init__.py,sha256=gRUwIUmAGtqriVyfIODkXjMPXmrukssjF7CdY1YXr3M,699
|
|
8
|
+
branta/v2/builder.py,sha256=CTNMm3XngQRZ8eRI3RF9dSf5zZqWvL8Wk0p42vlFg5E,1884
|
|
9
|
+
branta/v2/client.py,sha256=2UJ13p6VhI8amNz-mpMXJ6mFbJqPkJII073BiBo3SSw,5836
|
|
10
|
+
branta/v2/encryption.py,sha256=Fi1uDSJoXJ74x_Nzm6nH68-IxrHgndCtk_Gkhi2X86A,2047
|
|
11
|
+
branta/v2/parser.py,sha256=-UxRL6QpjVMqiHXAaGza87cY_8rlPvpTi61uDV5YF7c,5667
|
|
12
|
+
branta/v2/secret_generator.py,sha256=58liH4n6PBEkKlyAxz77RvwMZ42-IX4yaYywogGXW0M,180
|
|
13
|
+
branta/v2/serialization.py,sha256=cJbR3qZmZ06Kn9OyE9CZqtnMTbNdXWCrSa3WhDhJUdI,4468
|
|
14
|
+
branta/v2/service.py,sha256=2wFkGzUuDS154SCLkOTHES_np6NpsNepc5epiPVmA0o,11709
|
|
15
|
+
branta-3.2.0.dist-info/METADATA,sha256=Qfd5aQZvqU2kfH0YxWouFEIvgDziW3gbakheVe30fyE,5029
|
|
16
|
+
branta-3.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
17
|
+
branta-3.2.0.dist-info/licenses/LICENSE,sha256=sq9_YWHurat-d1FYVeJRU1nBsZZ94gwjUoqQNKGyX1w,1068
|
|
18
|
+
branta-3.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Branta Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|