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 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
@@ -0,0 +1,6 @@
1
+ class BrantaPaymentException(Exception):
2
+ pass
3
+
4
+
5
+ class QRParseException(Exception):
6
+ pass
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")
@@ -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,10 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+
5
+
6
+ class GuidSecretGenerator:
7
+ deterministic_nonce: bool = False
8
+
9
+ def generate(self) -> str:
10
+ return str(uuid.uuid4())
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.