diff --git a/CHANGELOG.md b/CHANGELOG.md
index d56c106..7cb2c50 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [Unreleased]
+
+### Fixed
+- `get_payments_by_qr_code` now verifies that the plaintext Bitcoin address parsed from a scanned QR code matches the address decrypted via `branta_id`/`branta_secret`, raising `BrantaPaymentException` with `reason=BrantaPaymentExceptionReason.Tampered` on mismatch. Closes a gap where an attacker could swap the visible address in a `bitcoin:` URI while leaving a legitimate, verified `branta_id`/`branta_secret` pair untouched (ported from `branta-js` 3.2.1)
+
## [3.2.0] - 2026-06-26
### Added
diff --git a/README.md b/README.md
index 99d22a0..d36a354 100644
--- a/README.md
+++ b/README.md
@@ -2,15 +2,71 @@
Python SDK for the [Branta](https://branta.pro) V2 API — payment destination lookup and registration with zero-knowledge encryption support.
-## Installation
+# Requirements
+
+- Python 3.9 or higher
+
+# Installation
```bash
pip install branta
```
-## Integration Guide
+# Integration Guide
+
+If you are an AI agent implementing this SDK, follow the steps below exactly. If you are a developer, use this as a reference for the recommended integration patterns.
+
+**1. Ask which side(s) of a payment they are implementing:**
+- **Send side** — the app is paying someone (e.g. a wallet). The user scans or pastes a destination and you verify it belongs to a known platform before funds are sent.
+- **Receive side** — the app is receiving payment (e.g. a checkout, POS, invoicing platform). You post destinations to Branta so wallets can verify them.
+- **Both** — some apps do both (e.g. an exchange or self-custodial wallet with invoice generation). Implement each side independently.
+
+If they are on the receive side, ask one follow-up:
+- **Platform** — single-tenant, one API key.
+- **Parent Platform** — multi-tenant, manages multiple child platforms. Two variants:
+ - **Shared key (Recommended)** — one API key for all children, no HMAC secret needed; tag the child per-payment with `set_child_platform()`. Default to this unless there's a specific reason for separate per-child keys.
+ - **Per-client keys** — each child has its own API key, and the parent signs every request with an HMAC secret to prove it originated from the parent; scope requests per-call. Use only if each child needs an independent, separately-revocable API key.
+
+**2. Follow the matching Quick Start section below.**
+
+**3. Apply these rules:**
+
+General (all types):
+- Always use `PrivacyMode.Strict`. Never switch to `PrivacyMode.Loose` unless there is no QR scanner and ZK is impossible.
+- Never call `BrantaClient` directly — always go through `BrantaService`.
+- Never show an error or "not verified" message when a lookup returns empty or throws. An empty result means the destination is unknown to Branta, not that it is malicious. Show nothing.
+- For `base_url`: use `BrantaServerBaseUrl.Production` only in production environments. Use `BrantaServerBaseUrl.Staging` everywhere else — including local development, CI, and staging/test environments.
+
+Send side (wallets):
+- Prefer `get_payments_by_qr_code` over `get_payments` — it handles multi-value ZK QR payloads correctly.
+- Only fall back to `get_payments` for copy/paste flows where there is no QR code.
+- If `result.payments` is empty or an exception is thrown, render nothing.
+- When `result.payments` is non-empty, display: the platform logo, the platform name (`payment.platform`), and the payment description (`payment.description`). Only render description when non-empty. Make the verification card a clickable link to `result.verify_url` — do not display the raw URL.
+- For the platform logo: on dark backgrounds use `payment.platform_logo_url`. On light backgrounds prefer `payment.platform_logo_light_url` when available, falling back to `payment.platform_logo_url`.
+- Optionally display `payment.parent_platform.logo_url` / `payment.parent_platform.logo_light_url` as a small secondary badge (e.g. corner icon). This is not required.
+
+Receive side (platforms):
+- Always call `.set_zk()` on the `PaymentBuilder` before calling `add_payment`. Plain-text destinations are rejected in `Strict` mode.
+- Store the `secret` returned by `add_payment` alongside the invoice — it is required to reconstruct the verify URL for the wallet.
+
+Receive side (parent platforms — per-client keys), in addition to the platform rules:
+- Include `hmac_secret` in `BrantaClientOptions` but omit `default_api_key` at service construction.
+- Pass per-call `BrantaClientOptions` with each child's API key to scope requests.
+
+Receive side (parent platforms — shared key), in addition to the platform rules:
+- Include `default_api_key` in `BrantaClientOptions`. Do not include `hmac_secret`.
+- Call `.set_child_platform(name, logo_url=..., logo_light_url=...)` on the builder to tag each payment with the child's branding.
+
+# Quick Start
-### Quick start
+## For Wallets
+
+Wallets should use `PrivacyMode.Strict`. Two flows are supported:
+
+- **Copy/paste**: call `get_payments` with the pasted text. Plain-text on-chain addresses will not return results in strict mode — they must be ZK-encoded. Hash-ZK destinations (bolt11, ark_address, silent_payment) work as plain text.
+- **QR scan**: call `get_payments_by_qr_code` with the raw QR text. This handles both on-chain (when the QR includes `branta_id` / `branta_secret`) and hash-ZK destinations.
+
+Always catch errors and show nothing on not-found — a missing record just means the address was not posted to Branta.
```python
import asyncio
@@ -24,27 +80,24 @@ options = BrantaClientOptions(
)
service = BrantaService(options)
-async def main():
- result = await service.get_payments_by_qr_code("bitcoin:bc1q...")
- if result.payments:
- print(result.payments[0].get_default_value())
-
-asyncio.run(main())
-```
-
-### Privacy modes
+async def lookup(input: str, is_qr_code: bool):
+ try:
+ result = (
+ await service.get_payments_by_qr_code(input)
+ if is_qr_code
+ else await service.get_payments(input)
+ )
-| Mode | Behaviour |
-|------|-----------|
-| `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`. |
-| `PrivacyMode.Loose` | Both plain and ZK lookups are permitted. |
+ if not result.payments:
+ # Not found — show nothing. The address may simply not exist in Branta.
+ return
-### Looking up a payment by QR code
+ # Render result.payments and result.verify_url
+ except Exception:
+ # Swallow errors — never surface a "not found" or lookup failure to the user.
+ pass
-```python
-result = await service.get_payments_by_qr_code(qr_text)
-# result.payments — list of Payment objects (empty if not found)
-# result.verify_url — always populated; share with the payer to verify
+asyncio.run(lookup("bitcoin:bc1q...", False))
```
Prefer `get_payments_by_qr_code` for QR-driven flows. It handles multi-destination payloads (`branta_id` / `branta_secret` fragments) automatically.
@@ -62,7 +115,42 @@ result = await service.get_payments(encrypted_address, destination_encryption_ke
result = await service.get_payments("lnbc...")
```
-### Registering a payment
+### No-QR-Code Flows
+
+When QR scanning is not available, three options exist. Choose one based on how much control you want to give users over privacy:
+
+**Option 1 — Keep Strict mode (no code changes)**
+
+Only hash-ZK destinations (bolt11, ark_address, silent_payment) will return results. Plain-text on-chain address lookups silently return empty. This is the safest default and requires no additional work.
+
+**Option 2 — Opt-in Loose mode (Recommended)**
+
+Add a user-facing setting (e.g. "Enable on-chain address verification"). Only switch to `PrivacyMode.Loose` when the user explicitly opts in — this sends on-chain addresses in plain text, so the choice should be theirs.
+
+```python
+options = (
+ BrantaClientOptions(base_url=BrantaServerBaseUrl.Production, privacy=PrivacyMode.Loose)
+ if user_opted_in
+ else None
+)
+
+result = await service.get_payments(input, options=options)
+```
+
+**Option 3 — Always Loose mode**
+
+Configure with `PrivacyMode.Loose` globally. All lookups including plain-text on-chain addresses are sent to Branta. Simplest, but gives users no privacy control.
+
+```python
+service = BrantaService(BrantaClientOptions(
+ base_url=BrantaServerBaseUrl.Production,
+ privacy=PrivacyMode.Loose,
+))
+```
+
+## For Platforms
+
+Platforms post payments to Branta so wallets can verify them. Use `PrivacyMode.Strict` and mark each destination ZK via `.set_zk()` on the `PaymentBuilder`.
```python
from branta.enums import DestinationType
@@ -87,6 +175,71 @@ result = await service.add_payment(payment, BrantaClientOptions(
# result.verify_url — share this URL to verify the payment
```
+## For Parent Platforms
+
+Choose a variant based on how API keys are structured. Only the per-client keys variant signs requests with HMAC — shared key needs none.
+
+
+Shared key — one API key covers all children (Recommended)
+
+Construct with a single API key; identify the child platform per-payment.
+
+```python
+from branta.enums import BrantaServerBaseUrl, DestinationType, PrivacyMode
+from branta.options import BrantaClientOptions
+from branta.v2 import BrantaService
+
+service = BrantaService(BrantaClientOptions(
+ base_url=BrantaServerBaseUrl.Production,
+ default_api_key="",
+ privacy=PrivacyMode.Strict,
+))
+
+payment = (
+ service.create_payment_builder()
+ .add_destination("bc1q...", DestinationType.BitcoinAddress).set_zk()
+ .set_child_platform("ChildBrand", logo_url="https://example.com/logo.png")
+ .set_ttl(600)
+ .build()
+)
+
+result = await service.add_payment(payment)
+```
+
+
+
+
+Per-client keys — each child has its own API key
+
+Construct the service with the shared HMAC secret only; pass each child's API key per-call.
+
+```python
+from branta.enums import BrantaServerBaseUrl, DestinationType, PrivacyMode
+from branta.options import BrantaClientOptions
+from branta.v2 import BrantaService
+
+service = BrantaService(BrantaClientOptions(
+ base_url=BrantaServerBaseUrl.Production,
+ hmac_secret="",
+ privacy=PrivacyMode.Strict,
+))
+
+payment = (
+ service.create_payment_builder()
+ .add_destination("bc1q...", DestinationType.BitcoinAddress).set_zk()
+ .set_ttl(600)
+ .build()
+)
+
+# Scope to the child platform's API key per-call
+result = await service.add_payment(payment, BrantaClientOptions(
+ base_url=BrantaServerBaseUrl.Production,
+ default_api_key="",
+))
+```
+
+
+
### Validating an API key
```python
@@ -105,6 +258,15 @@ service = BrantaService(default_options)
result = await service.get_payments("lnbc...", options=override_options)
```
+# Privacy
+
+`PrivacyMode` controls whether plain-text on-chain lookups are allowed.
+
+| Value | Behavior |
+|-------|----------|
+| `PrivacyMode.Strict` (default) | Only ZK lookups. `get_payments` raises `BrantaPaymentException` for plain addresses. `get_payments_by_qr_code` returns an empty `PaymentsResult` with a populated `verify_url`. `add_payment` raises if any destination has `is_zk=False`. |
+| `PrivacyMode.Loose` | Both plain and ZK lookups are permitted. |
+
## ZK destination types
| Type | Encryption |
@@ -114,7 +276,31 @@ result = await service.get_payments("lnbc...", options=override_options)
| `ArkAddress` | Deterministic: SHA256 of lowercase address |
| `SilentPayment` | Deterministic: SHA256 of lowercase address |
-## Development
+# BrantaService
+
+The primary service class. Always use `BrantaService` — never call `BrantaClient` directly.
+
+**Prefer `get_payments_by_qr_code` for integrations.** It parses the raw QR text and correctly resolves multiple ZK values in a single scan. `get_payments` only handles a single destination value and does not support multi-value ZK lookups.
+
+```python
+async def get_payments_by_qr_code(qr_text: str, options: Optional[BrantaClientOptions] = None) -> PaymentsResult: ...
+async def get_payments(destination_value: str, destination_encryption_key: Optional[str] = None, options: Optional[BrantaClientOptions] = None) -> PaymentsResult: ...
+async def add_payment(payment: Payment, options: Optional[BrantaClientOptions] = None) -> AddPaymentResult: ...
+async def is_api_key_valid(options: Optional[BrantaClientOptions] = None) -> bool: ...
+```
+
+`PaymentsResult` contains the list of matching `payments` and the `verify_url` to display to the user — `verify_url` is always returned, even when `payments` is empty.
+
+→ [`branta/v2/service.py`](branta/v2/service.py)
+
+# Release
+
+- Update `version` in `pyproject.toml`
+- `pip install build twine` (one-time)
+- `python -m build`
+- `twine upload dist/*`
+
+# Development
```bash
pip install -e ".[dev]"
@@ -122,3 +308,7 @@ pytest tests/ --ignore=tests/test_integration.py # unit tests
pytest tests/test_integration.py # integration (requires network)
coverage run -m pytest tests/ --ignore=tests/test_integration.py && coverage report
```
+
+# Responsible Disclosure
+
+Found critical bugs/vulnerabilities? Please email them to support@branta.pro. Thanks!
diff --git a/branta/__init__.py b/branta/__init__.py
index b305034..94453b2 100644
--- a/branta/__init__.py
+++ b/branta/__init__.py
@@ -1,5 +1,5 @@
from branta.enums import BrantaServerBaseUrl, DestinationType, PrivacyMode
-from branta.exceptions import BrantaPaymentException, QRParseException
+from branta.exceptions import BrantaPaymentException, BrantaPaymentExceptionReason, QRParseException
from branta.extensions import (
get_api_key,
get_base_url,
@@ -22,6 +22,7 @@
"DestinationType",
"PrivacyMode",
"BrantaPaymentException",
+ "BrantaPaymentExceptionReason",
"QRParseException",
"BrantaClientOptions",
"Payment",
diff --git a/branta/exceptions.py b/branta/exceptions.py
index 8be9de5..f557439 100644
--- a/branta/exceptions.py
+++ b/branta/exceptions.py
@@ -1,5 +1,14 @@
+from enum import Enum
+
+
+class BrantaPaymentExceptionReason(Enum):
+ Tampered = "tampered"
+
+
class BrantaPaymentException(Exception):
- pass
+ def __init__(self, message: str, reason: "BrantaPaymentExceptionReason | None" = None) -> None:
+ super().__init__(message)
+ self.reason = reason
class QRParseException(Exception):
diff --git a/branta/v2/service.py b/branta/v2/service.py
index 05b98e2..33142ce 100644
--- a/branta/v2/service.py
+++ b/branta/v2/service.py
@@ -4,7 +4,7 @@
from urllib.parse import quote
from branta.enums import DestinationType, PrivacyMode
-from branta.exceptions import BrantaPaymentException
+from branta.exceptions import BrantaPaymentException, BrantaPaymentExceptionReason
from branta.extensions import (
get_base_url,
get_hash_zk_type,
@@ -21,6 +21,15 @@
from branta.v2.secret_generator import GuidSecretGenerator
+def _addresses_match(a: str, b: str) -> bool:
+ def is_bech32(v: str) -> bool:
+ return v.lower().startswith("bc1")
+
+ if is_bech32(a) and is_bech32(b):
+ return a.lower() == b.lower()
+ return a == b
+
+
class BrantaService:
def __init__(
self,
@@ -51,10 +60,15 @@ async def get_payments_by_qr_code(
for d in parser.destinations
if get_hash_zk_type(d.value) is not None
]
+ on_chain_address = next(
+ (d.value for d in parser.destinations if d.type == DestinationType.BitcoinAddress),
+ None,
+ )
return await self._get_payments_for_zk(
parser.on_chain_encryption_text,
parser.on_chain_encryption_secret,
additional_values,
+ on_chain_address,
options,
)
@@ -192,13 +206,16 @@ async def _get_payments_for_zk(
lookup_value: str,
encryption_key: Optional[str],
additional_hash_values: List[str],
+ expected_on_chain_address: Optional[str],
options: Optional[BrantaClientOptions],
) -> PaymentsResult:
payments = await self._client.get_payments(lookup_value, options)
keys: Dict[str, str] = {}
for payment in payments:
- await self._decrypt_destinations(payment, lookup_value, encryption_key, None, keys)
+ await self._decrypt_destinations(
+ payment, lookup_value, encryption_key, None, keys, expected_on_chain_address
+ )
for value in additional_hash_values:
await self._decrypt_hash_zk_destinations(payment, value, keys)
@@ -233,6 +250,7 @@ async def _decrypt_destinations(
encryption_key: Optional[str],
hash_zk_type: Optional[DestinationType],
keys: Dict[str, str],
+ expected_on_chain_address: Optional[str] = None,
) -> None:
for destination in payment.destinations:
destination.is_encrypted = bool(destination.is_zk)
@@ -243,13 +261,22 @@ async def _decrypt_destinations(
if encryption_key is None:
continue
try:
- destination.value = await self._aes_encryption.decrypt(destination.value, encryption_key)
- destination.is_encrypted = False
- if destination.zk_id is not None and destination.zk_id not in keys:
- keys[destination.zk_id] = encryption_key
- await self._try_decrypt_metadata(payment, destination, encryption_key)
+ decrypted = await self._aes_encryption.decrypt(destination.value, encryption_key)
except Exception:
- pass
+ continue
+
+ if expected_on_chain_address is not None and not _addresses_match(decrypted, expected_on_chain_address):
+ raise BrantaPaymentException(
+ "The Bitcoin address in the QR code does not match the address verified by Branta. "
+ "The QR code may have been tampered with.",
+ BrantaPaymentExceptionReason.Tampered,
+ )
+
+ destination.value = decrypted
+ destination.is_encrypted = False
+ if destination.zk_id is not None and destination.zk_id not in keys:
+ keys[destination.zk_id] = encryption_key
+ await self._try_decrypt_metadata(payment, destination, encryption_key)
elif hash_zk_type is not None and destination.type == hash_zk_type:
key = to_normalized_hash(destination_value)
try:
diff --git a/tests/test_service.py b/tests/test_service.py
index 25438c7..031630d 100644
--- a/tests/test_service.py
+++ b/tests/test_service.py
@@ -5,7 +5,7 @@
import pytest
from branta.enums import BrantaServerBaseUrl, DestinationType, PrivacyMode
-from branta.exceptions import BrantaPaymentException
+from branta.exceptions import BrantaPaymentException, BrantaPaymentExceptionReason
from branta.extensions import to_normalized_hash
from branta.models import Payment
from branta.options import BrantaClientOptions
@@ -237,6 +237,107 @@ async def get_side_effect(lookup, opts=None, signal=None):
aes_mock.decrypt.assert_any_await(ENCRYPTED_BOLT11, BOLT11_HASH)
+SWAPPED_ADDRESS = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"
+BECH32_ADDRESS = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
+ENCRYPTED_BECH32_ADDRESS = "encrypted-bech32-address"
+
+
+def zk_bech32_payment() -> Payment:
+ return PaymentBuilder().add_destination(ENCRYPTED_BECH32_ADDRESS, DestinationType.BitcoinAddress).set_zk().build()
+
+
+# ===== get_payments_by_qr_code address binding =====
+
+class TestGetPaymentsByQrCodeAddressBinding:
+ async def test_swapped_address_rejects(self, service, client_mock, aes_mock):
+ async def get_side_effect(lookup, opts=None, signal=None):
+ if lookup == ENCRYPTED_BITCOIN_ADDRESS:
+ return [zk_bitcoin_payment()]
+ return []
+ client_mock.get_payments = AsyncMock(side_effect=get_side_effect)
+
+ qr = f"bitcoin:{SWAPPED_ADDRESS}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}"
+ with pytest.raises(BrantaPaymentException) as exc_info:
+ await service.get_payments_by_qr_code(qr)
+ assert exc_info.value.reason == BrantaPaymentExceptionReason.Tampered
+
+ async def test_matching_address_does_not_throw(self, service, client_mock, aes_mock):
+ async def get_side_effect(lookup, opts=None, signal=None):
+ if lookup == ENCRYPTED_BITCOIN_ADDRESS:
+ return [zk_bitcoin_payment()]
+ return []
+ client_mock.get_payments = AsyncMock(side_effect=get_side_effect)
+
+ qr = f"bitcoin:{BITCOIN_ADDRESS}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}"
+ result = await service.get_payments_by_qr_code(qr)
+ assert result.payments[0].destinations[0].value == BITCOIN_ADDRESS
+
+ async def test_uppercase_bech32_qr_matches_lowercase_registered(self, service, client_mock, aes_mock):
+ async def decrypt_side_effect(encrypted_value, secret):
+ if encrypted_value == ENCRYPTED_BECH32_ADDRESS and secret == SECRET:
+ return BECH32_ADDRESS
+ return ""
+ aes_mock.decrypt = AsyncMock(side_effect=decrypt_side_effect)
+
+ async def get_side_effect(lookup, opts=None, signal=None):
+ if lookup == ENCRYPTED_BECH32_ADDRESS:
+ return [zk_bech32_payment()]
+ return []
+ client_mock.get_payments = AsyncMock(side_effect=get_side_effect)
+
+ qr = f"bitcoin:{BECH32_ADDRESS.upper()}?branta_id={ENCRYPTED_BECH32_ADDRESS}&branta_secret={SECRET}"
+ result = await service.get_payments_by_qr_code(qr)
+ assert result.payments[0].destinations[0].value == BECH32_ADDRESS
+
+ async def test_base58_case_mismatch_rejects(self, service, client_mock, aes_mock):
+ async def get_side_effect(lookup, opts=None, signal=None):
+ if lookup == ENCRYPTED_BITCOIN_ADDRESS:
+ return [zk_bitcoin_payment()]
+ return []
+ client_mock.get_payments = AsyncMock(side_effect=get_side_effect)
+
+ qr = f"bitcoin:{BITCOIN_ADDRESS.lower()}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}"
+ with pytest.raises(BrantaPaymentException) as exc_info:
+ await service.get_payments_by_qr_code(qr)
+ assert exc_info.value.reason == BrantaPaymentExceptionReason.Tampered
+
+ async def test_lightning_qr_with_zk_params_no_plain_address_decrypts_without_comparison(
+ self, service, client_mock, aes_mock
+ ):
+ async def get_side_effect(lookup, opts=None, signal=None):
+ if lookup == ENCRYPTED_BITCOIN_ADDRESS:
+ return [zk_bitcoin_payment()]
+ return []
+ client_mock.get_payments = AsyncMock(side_effect=get_side_effect)
+
+ qr = f"lightning:{BOLT11_INVOICE}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}"
+ result = await service.get_payments_by_qr_code(qr)
+ assert result.payments[0].destinations[0].value == BITCOIN_ADDRESS
+
+ async def test_combined_zk_qr_swapped_address_rejects(self, service, client_mock, aes_mock):
+ payment = (
+ PaymentBuilder()
+ .add_destination(ENCRYPTED_BITCOIN_ADDRESS, DestinationType.BitcoinAddress).set_zk()
+ .add_destination(ENCRYPTED_BOLT11, DestinationType.Bolt11).set_zk()
+ .add_destination(ENCRYPTED_ARK_ADDRESS, DestinationType.ArkAddress).set_zk()
+ .build()
+ )
+
+ async def get_side_effect(lookup, opts=None, signal=None):
+ if lookup == ENCRYPTED_BITCOIN_ADDRESS:
+ return [payment]
+ return []
+ client_mock.get_payments = AsyncMock(side_effect=get_side_effect)
+
+ qr = (
+ f"bitcoin:{SWAPPED_ADDRESS}?branta_id={ENCRYPTED_BITCOIN_ADDRESS}&branta_secret={SECRET}"
+ f"&lightning={BOLT11_INVOICE}&ark={ARK_ADDRESS}"
+ )
+ with pytest.raises(BrantaPaymentException) as exc_info:
+ await service.get_payments_by_qr_code(qr)
+ assert exc_info.value.reason == BrantaPaymentExceptionReason.Tampered
+
+
# ===== get_payments =====
class TestGetPayments: