Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
236 changes: 213 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.

<details>
<summary>Shared key — one API key covers all children (Recommended)</summary>

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="<shared-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)
```

</details>

<details>
<summary>Per-client keys — each child has its own API key</summary>

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="<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="<child-api-key>",
))
```

</details>

### Validating an API key

```python
Expand All @@ -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 |
Expand All @@ -114,11 +276,39 @@ 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]"
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!
3 changes: 2 additions & 1 deletion branta/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,6 +22,7 @@
"DestinationType",
"PrivacyMode",
"BrantaPaymentException",
"BrantaPaymentExceptionReason",
"QRParseException",
"BrantaClientOptions",
"Payment",
Expand Down
11 changes: 10 additions & 1 deletion branta/exceptions.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
Loading
Loading