Metadata-Version: 2.4
Name: tuladb-sdk
Version: 0.0.1.dev0
Summary: TulaDB Python SDK
Author: TulaDB
License: TulaDB Source-Available License
Project-URL: Homepage, https://tuladb.com
Project-URL: Documentation, https://tuladb.com
Project-URL: Repository, https://github.com/TulaDB/tuladb-sdk
Keywords: tuladb,ledger,payments,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Database
Classifier: Topic :: Office/Business :: Financial
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

# TulaDB Python SDK

Last updated: 2026-05-07

## Available functions

```python
client.create_ledger(ledger)
client.get_ledger(ledger_id)
client.list_ledgers()
client.create_account(account)
client.ping()
client.transfer(transfer)
client.reserve(transfer)
client.reject(reject)
client.reject_by_key(source_id, currency, txn_id, *, ledger_id=1, amount=0, debit_account_id=0, credit_account_id=0, reason_code=0, custom=None)
client.commit(transfer_id)
client.rollback(transfer_id)
client.commit_by_key(source_id, ledger_id, currency, txn_id)
client.rollback_by_key(source_id, ledger_id, currency, txn_id)
client.tiered_transfer(tiered_transfer)
client.tiered_reserve(tiered_transfer)
client.tiered_commit_transfer_id(transfer_id)
client.tiered_rollback_transfer_id(transfer_id)
client.tiered_commit_by_key(source_id, ledger_id, currency, txn_id)
client.tiered_rollback_by_key(source_id, ledger_id, currency, txn_id)
client.get_balance(AccountKey(ledger_id, currency, account_id), min_batch_seq=0)
client.list_account_balances(ledger_id, account_id)   # one row per currency
client.get_transaction(transfer_id)
client.get_transaction_by_key(source_id, ledger_id, currency, txn_id)
client.get_history(AccountKey(ledger_id, currency, account_id), after_seq=0, limit=100)
client.get_account_history(AccountKey(ledger_id, currency, account_id), after_timestamp=0, limit=100)
client.update_account_limit_low(AccountKey(ledger_id, currency, account_id), limit_low)
client.update_account_limit_high(AccountKey(ledger_id, currency, account_id), limit_high)
client.set_account_active(AccountKey(ledger_id, currency, account_id), active)
client.get_cluster_status()
client.list_accounts(offset=0, limit=256)
client.list_transfers(after_seq=0, limit=256)
client.list_pending_reservations(after_seq=0, limit=256)
client.list_transfers_extended(max_records=50000, page_limit=256, include_pending=True)
client.list_wrapped_transactions(max_records=50000, page_limit=256, include_pending=True)
client.transfer_batch(transfers, pipeline_depth=None)
client.reserve_batch(transfers, pipeline_depth=None)
client.commit_batch(transfer_ids, pipeline_depth=None)
client.rollback_batch(transfer_ids, pipeline_depth=None)
client.tiered_transfer_batch(tiered_transfers, pipeline_depth=None)
client.tiered_reserve_batch(tiered_transfers, pipeline_depth=None)
client.tiered_commit_batch(transfer_ids, pipeline_depth=None)
client.tiered_rollback_batch(transfer_ids, pipeline_depth=None)
client.close()

LedgerClient.derive_transfer_id(source_id, ledger_id, currency, txn_id)
LedgerClient.derive_transfer_id_bytes(source_id, ledger_id, currency, txn_id)
LedgerClient.classify_result(result)
LedgerClient.classify_status_code(status_code)
LedgerClient.raise_for_result(result, allow_duplicate=True)
LedgerClient.status_is_final(status_code)
```

**Account key (wire version 3).** An account is named by
`(ledger_id, currency, id)`: one business id holds one balance per currency,
and the ledger is the tenant boundary. Every account-addressed call takes an
`AccountKey(ledger_id, currency, id)`; passing a bare id raises `TypeError`
(that was the pre-v3 call shape). A key that names no account answers
`ERR_NOT_FOUND` — never another key's account. Creating an existing key with
different creation attributes answers `ERR_ACCOUNT_CONFLICT` (44); an
identical retry is `OK_DUPLICATE`. `AccountMetadataEvent` carries `ledger_id`
and `currency`. This SDK speaks wire version 3 only; an engine on version 2 is
refused at the frame header.

`SmartLedgerClient` does not re-route or retry a deterministic verdict
(`LedgerClient.FINAL_STATUSES`: 1, 2, 3, 5, 6, 7, 8, 11, 22, 35, 36, 41, 42,
44 — the same set as the C SDK's `ledgerd_status_is_final`). See
[Status classes and retries](#status-classes-and-retries).

Deterministic transfer-id derivation is standardized as:
`SHA3-256("<len(source)>:<source>|<len(ledger)>:<ledger>|<len(currency)>:<currency>|<len(txn)>:<txn>")`
where `txn` is exact validated printable ASCII with no trimming or case-folding.

SDK-level transaction status handling is also standardized:
- `classify_result(...)` returns `status_code`, `status`, `duplicate`, `accepted`
- `raise_for_result(...)` can enforce strict handling (optionally rejecting duplicates)

`ledger_id` note:
- low-level wire dataclasses (`TransferCreate`, `TieredTransferCreate`) still
  carry a concrete `ledger_id`
- higher-level API layers (dashboard and `tuladb-sdk-server-python`) can infer
  `ledger_id` from account ids when requests are unambiguous

The current starter speaks the TulaDB wire protocol:

- send writes to the main client port, typically `7700`
- send read-only balance traffic to the query port, typically `7800`
- `get_balance()` works on either port, but the query port keeps read load off
  the write listener

## Usage

```python
from tuladb import LedgerClient
from tuladb.models import AccountCreate, AccountKey, LedgerInstance, TransferCreate, RejectCreate, LEDGER_MODE_DEFAULT
from tuladb.protocol import make_u128

with LedgerClient("127.0.0.1", 7700) as c:
    with LedgerClient("127.0.0.1", 7800) as q:
        ledger = c.create_ledger(LedgerInstance(
            ledger_id=0,  # 0 = engine auto-allocates the next id
            currency=840,
            mode=LEDGER_MODE_DEFAULT,
        ))
        ledger_id = ledger.ledger_id

        debit = make_u128(ledger_id, 1)
        credit = make_u128(ledger_id, 2)

        c.create_account(AccountCreate(id=debit, ledger_id=ledger_id, currency=840, flags=1))
        c.create_account(AccountCreate(id=credit, ledger_id=ledger_id, currency=840, flags=2))

        result = c.transfer(TransferCreate(
            source_id=1,
            ledger_id=ledger_id,
            debit_account_id=debit,
            credit_account_id=credit,
            amount=100,
            txn_id=f"py-sdk-{ledger_id}-000001",
        ))

        print(f"{result.transfer_id:064x}")

        balance = q.get_balance(AccountKey(ledger_id, 840, debit))
        print(balance.available_balance)

        reject_result = c.reject(RejectCreate(
            source_id=1,
            currency=840,
            txn_id=f"py-sdk-reject-{ledger_id}-000001",
            ledger_id=ledger_id,
            # Optional context, recorded verbatim on the declined record.
            amount=100,
            debit_account_id=debit,
            credit_account_id=credit,
            reason_code=4207,   # your taxonomy
        ))
        print(reject_result.status)
```

## By-key ids: always pass the ledger

`commit_by_key`, `rollback_by_key`, the tiered forms, `get_transaction_by_key`
and `derive_transfer_id` take `(source_id, ledger_id, currency, txn_id)`. The
old 3-argument form `(source_id, currency, txn_id)` still works but raises a
`DeprecationWarning` (pointing at your call): it silently used ledger 1, so on
any other ledger it derived the wrong transfer id. Run your tests with
`-W error::DeprecationWarning` to find the call sites. Source, ledger and
currency must fit a u32, and a ledger id above 0xFFFFFFEF (the engine's
reserved range) is refused. (`reject_by_key` takes `ledger_id` as a keyword
that defaults to 1; pass it explicitly.)

## Decimal amounts

```python
from tuladb import CurrencyScales, CurrencyNotRegistered, to_minor, format_minor

to_minor("800.00", 2)        # 80000 (str or exact Decimal only; float/int -> TypeError)
format_minor(-1, 2)          # "-0.01"

scales = CurrencyScales(client)         # thread-safe; any client with get_currency
minor = scales.to_minor(124, "800.00")  # CAD's registered scale, from the engine
scales.format(124, minor)               # "800.00"
```

Refusals raise `tuladb.AmountError` (a `ValueError`) with `.reason`; an
unregistered currency raises `CurrencyNotRegistered`. The rules are [`contracts/amount-vectors.json`](../contracts/amount-vectors.json): exact, never rounded; more fractional digits than the scale is `too_many_decimals` (`"800.000"` at scale 2 included); anything but ASCII `digits[.digits]` is `invalid`; past int64 is `overflow`. The cache asks the engine once per currency and keeps a found scale forever; a miss is remembered for 2 s, so a currency registered elsewhere resolves within 2 s, and one created through the cache resolves at once.

`currency.amount_decimal_to_int` holds str/Decimal input to the same rules
(changed: it used to strip whitespace and accept a sign, an exponent and
non-ASCII digits); an int still passes through as minor units.

## TLS/mTLS to `tuladbd`

Raw clients support native daemon TLS/mTLS:

```python
from tuladb import LedgerClient, LedgerTlsOptions

tls = LedgerTlsOptions(
    enabled=True,
    ca_file="/run/secrets/tuladb-ca.crt",
    cert_file="/run/secrets/client.crt",
    key_file="/run/secrets/client.key",
    server_name="node1.internal",
)

with LedgerClient("node1", 7700, tls_options=tls) as c:
    c.ping()
```

The same settings can be provided with `TULADB_CLIENT_TLS_ENABLED`,
`TULADB_CLIENT_TLS_CA_FILE`, `TULADB_CLIENT_TLS_CERT_FILE`,
`TULADB_CLIENT_TLS_KEY_FILE`, `TULADB_CLIENT_TLS_SERVER_NAME`, and
`TULADB_CLIENT_TLS_VERIFY`.

## Connection auth (API key / bearer token)

```python
from tuladb import LedgerAuth, LedgerClient
c = LedgerClient(host, port, tls_enabled=True, tls_ca_file=ca, auth=LedgerAuth.api_key(key))
c.connect()
```

`SmartLedgerClient(..., auth=...)` and `LedgerClientPool(..., auth=...)` pass
it to every node connection. `MSG_AUTH` goes out on every `connect()` (each
reconnect after a reset included) before any request. Plaintext refuses it
unless `allow_insecure=True` or `TULADB_CLIENT_AUTH_ALLOW_INSECURE=1`. A
rejection raises `LedgerAuthError` (`status_code` 35; `None` when the client
refused to send) and is never retried by the smart client. `repr` shows only
the length; the credential lives in a `bytearray` that `LedgerAuth.wipe()`
zeroes (pass bytes, not `str`: a `str` cannot be cleared). See the root
README, "Connection auth".

## Throughput

One `LedgerClient` owns one socket, and the wire carries no correlation id, so
a complete request/response exchange on that socket is serialised. Concurrency
therefore comes from **connections** and from **frames in flight per
connection** — not from caller threads. There are three knobs, and they
compose:

| knob | what it does | when it helps |
|---|---|---|
| `pipeline_depth` | batch frames in flight on one connection | a caller-supplied batch larger than `TXN_BATCH_MAX` (1024) |
| `LedgerClientPool(size=N)` | N connections, requests sharded across them | any concurrent workload |
| `BufferedLedgerClient` | coalesces concurrent one-at-a-time calls into batch frames | many threads each posting single transfers |

### Caller supplies the batch

`transfer_batch` splits the list into 1024-transfer frames. By default it sends
one and waits for it before sending the next, so throughput is capped at
`1024 / round_trip`. `pipeline_depth` sends that many frames before reading any
response, which is what removes the round trip from the critical path:

```python
from tuladb import LedgerClient, LedgerClientPool

with LedgerClient("127.0.0.1", 7700, pipeline_depth=8) as c:
    results = c.transfer_batch(transfers)          # 8 frames in flight
    results = c.transfer_batch(transfers, pipeline_depth=16)   # or per call

# More in flight than one connection can carry: shard across a pool.
with LedgerClientPool("ledger://n1:7700,n2:7700", size=16, pipeline_depth=8) as pool:
    results = pool.transfer_batch(transfers)
    # 16 connections × 8 frames × 256 transfers in flight
```

Results always come back in input order regardless of depth or pool size.
Depth is clamped to `LedgerClient.MAX_PIPELINE_DEPTH` (64): the window is sent
before any of it is read, so an unbounded depth deadlocks against the server's
socket buffer. `pipeline_depth` defaults to `1`, which is the previous
behaviour byte for byte.

### Caller does not batch

`BufferedLedgerClient` lets application code keep calling `transfer()`,
`reserve()`, `commit()`, `rollback()` and the tiered forms one at a time, and
turns whatever is outstanding into a single batch frame. The batch size is set
by the offered load rather than by a constant, so it is right at both ends of
the range: no batching delay when one caller is idle-polling, full frames when
64 threads are hammering it.

```python
from tuladb import BufferedLedgerClient, LedgerClientPool

pool = LedgerClientPool("ledger://n1:7700,n2:7700", size=8, pipeline_depth=4)
buffered = BufferedLedgerClient(pool, flush_interval_ms=2.0, pipeline_depth=4)

# Called from any number of threads; each gets its own result back.
result = buffered.transfer(transfer)
```

Each operation type has its own queue, so commits batch with commits and do not
queue behind transfers — a 2PC workload batches on both legs. Reads, registry
and admin calls flush what is pending first, so a read never observes less than
what a caller has already been told was committed.

Errors follow the rule every TulaDB SDK shares: a buffered item's failure (its
own status, a refusal of its whole frame, a transport error) is raised only to
the caller that submitted it. The flush-first before a read waits for pending
frames but never raises their errors -- the read then succeeds or fails on its
own. `flush()` returns once everything queued at call time has been sent and
answered and does not raise per-item or per-frame errors (they already reached
their callers); it raises `LedgerError` only on a closed client. `close()` never
raises a frame's error either.

`flush_workers` defaults to the pool size (1 for a single client), giving one
flusher per connection. `pipeline_depth` only engages here once queued work
exceeds one 1024-transfer frame; below that, more in-flight work comes from more
connections.

### Routing

By default the client discovers the leader and sends everything to it. The
override is absolute — a pinned client never probes topology and never
re-routes on its own:

```python
from tuladb import SmartLedgerClient, ROUTE_ROUND_ROBIN

# default: discover and follow the leader
smart = SmartLedgerClient("ledger://n1:7700,n2:7700,n3:7700")

# pin every operation to one node
smart = SmartLedgerClient(nodes, pinned_node="n2:7700")

# or from the environment, with no code change
#   TULADB_PIN_NODE=n2:7700
#   TULADB_ROUTE_POLICY=leader | pinned | round_robin

# spread operations across nodes (pairs with follower reads)
smart = SmartLedgerClient(nodes, route_policy=ROUTE_ROUND_ROBIN)
```

`LedgerClientPool` takes the same `route_policy` / `pinned_node` arguments.

Leader lookup is cached: a routed operation reads one attribute and sends. A
leader change surfaces as an error on the next operation, which drops the
connection and retries against a freshly probed leader. Pass
`background_refresh=True` to track topology on a daemon thread instead, so the
change is noticed before an operation trips over it.

### Measuring

`tools/tuladb_loadgen.py` mirrors the `tuladb_bench_mt --server` knobs
(`--threads`, `--batch-size`, `--pipeline-depth`, `--duration-s`) so a Python
result can be compared against the C load generator on the same daemon.

### Retry safety

Every batch operation is keyed by `txn_id` and the daemon deduplicates, so a
window lost to a socket break can be resubmitted whole: the parts that landed
come back `OK_DUPLICATE`. That is what makes deep pipelining and pool sharding
safe to retry.

### Status classes and retries

Every engine status falls in one of four classes (the source of truth is
`contracts/status-codes.env`, which this SDK's tests check against):

- **final** — `LedgerClient.status_is_final(code)` (`FINAL_STATUSES`): the
  same answer on every node and every retry. Never retry; fix the request.
- **retryable** — `LedgerClient.status_is_retryable(code)` (also
  `tuladb.status_is_retryable`; `RETRYABLE_STATUSES` = 4 `ERR_CAPACITY`, 10,
  13, 14, 18, 21, 38, 48): the server shed load or hit a transient
  failure and nothing was written. Back off and retry with the SAME identity
  (`txn_id`): the duplicate check makes it safe whichever attempt landed.
- **ambiguous** — `LedgerClient.status_is_ambiguous(code)` (also
  `tuladb.status_is_ambiguous`; `AMBIGUOUS_STATUSES` = 12
  `ERR_QUORUM_TIMEOUT`): transient, but the write **may have committed** (a leader that lost quorum or leadership mid-write cannot learn the outcome). The smart client retries it under
  the same identity, whose replay answers the original's outcome, but never
  resends `create_ledger` with `ledger_id` 0: it raises
  `LedgerAmbiguousError` (a `LedgerError`, `status_code` 12, "outcome
  unknown") so you can check `list_ledgers` before acting again.
- **other** — anything else (incl. unknown codes): depends on state that can
  change (a lagging node, an account not yet created). Look at it before
  retrying by hand.

`SmartLedgerClient` (and `LedgerClientPool`) raises a final status at once and
retries every other server status, including a single write's item status,
after a full-jitter backoff -- except on `transfer`, `reserve`,
`tiered_transfer`, `tiered_reserve` and `reject`, where only a retryable status
is retried: the engine records every other decline under the request's
identity (an "other" status such as 9 `ERR_NOT_FOUND` or 26
`ERR_DEBIT_ACCOUNT_INACTIVE` included), so a retry of the same `txn_id` can only
replay it. Fix the cause and send a new `txn_id`. The backoff: `delay(n)` is uniform in
`[0, min(retry_max_delay_ms, retry_initial_delay_ms * 2^n)]`, `n` counting the
backed-off retries (0 for the first). A transport failure or "not the leader"
re-routes at once the first time (not counted in `n`), then backs off the same
way. `create_ledger` with `ledger_id=0` (the server assigns the id) has no
identity, so it is retried only after a retryable status or a not-the-leader
refusal; after a transport failure its outcome is unknown and the error is
raised. No retry is started past
`retry_max_elapsed_ms` after the first attempt; the last error is raised.
`retry_attempts` is the number of retries after the first attempt (default
`tuladb.RETRY_ATTEMPTS`, 1, so two attempts in all), as in the other SDKs. Before this release it was
the total number of attempts.

```python
smart = SmartLedgerClient(
    nodes,
    retry_attempts=5,
    retry_initial_delay_ms=50,     # RETRY_INITIAL_DELAY_MS
    retry_max_delay_ms=2000,       # RETRY_MAX_DELAY_MS
    retry_max_elapsed_ms=15000,    # RETRY_MAX_ELAPSED_MS
)
```

**Batch items are not retried.** A batch reply carries one status per item;
resubmit the items whose status is retryable, under their same identities:

```python
results = smart.transfer_batch(items)
again = [t for t, r in zip(items, results) if LedgerClient.status_is_retryable(r.status)]
```

## Current coverage

Implemented wire-compatible helpers:

- `create_account`
- `transfer`
- `reserve`
- `reject` (synthetic non-mutating rejected transaction write for idempotency)
- `reject_by_key`
- `commit`
- `rollback`
- `commit_by_key`
- `rollback_by_key`
- `tiered_transfer`
- `tiered_reserve`
- `tiered_commit_transfer_id`
- `tiered_rollback_transfer_id`
- `tiered_commit_by_key`
- `tiered_rollback_by_key`
- `get_balance`
- `list_account_balances`
- `get_transaction`
- `get_transaction_by_key`
- `get_history`
- `get_cluster_status`
- `list_accounts`
- `list_transfers`
- `list_pending_reservations`
- `list_transfers_extended` (SDK-level merged transfer view; keeps multi-leg rows)
- `list_wrapped_transactions` (logical transaction view; multi-tier legs wrapped into one transaction object with timestamp + per-leg details)
- `transfer_batch`
- `reserve_batch`
- `commit_batch`
- `rollback_batch`
- `tiered_transfer_batch`
- `tiered_reserve_batch`
- `tiered_commit_batch`
- `tiered_rollback_batch`

`reject`/`reject_by_key` take a caller-owned `reason_code` — any unsigned
32-bit value from your own taxonomy, recorded verbatim on the declined record.
`0xFFFF0000`–`0xFFFFFFFF` is reserved for engine-originated declines and is
refused, so an engine decline can never be forged by a caller.

Notes:
- Required: `source_id`, `ledger_id`, `currency`, `txn_id` — the four fields
  that form the operation identity the decline is recorded under. `source_id`
  must be `> 0`.
- Optional: `amount`, `debit_account_id`, `credit_account_id`. A reject moves no
  value, so none of them is needed; supply what the declined request carried and
  the engine records it as given. Account ids are not looked up, so an id that
  does not exist is accepted — often it is the reason for the decline.
- The engine fixes the recorded status to `ERR_UPSTREAM_REJECTED` (41);
  `reason_code` is your classification alongside it, not a substitute for it.
- the buffered client flushes off-lock from a persistent flusher thread, so
  producers keep filling the next batch while the current one is on the wire;
  `close()` stops the flushers, drains what is queued on the calling thread, and
  only then closes the socket.
- transfer batching is derived from `TRANSFER_CREATE.size` and encoded via
  `encode_transfer(...)` per item (no magic fixed record-size constant).
- all transaction batch write APIs share one ceiling: `LedgerClient.TXN_BATCH_MAX` (`1024`).

## Phase 3 grouped transaction view

Phase 3 does not require a Python wire-protocol change. The grouped transaction
view is a projection over existing transfer/history rows.

Use the existing high-level helper when available:

```python
rows = client.list_wrapped_transactions(
    max_records=50000,
    page_limit=256,
    include_pending=True,
)

for tx in rows:
    print(tx.get("txn_id"), tx.get("state"), tx.get("leg_count"))
```

For a dashboard REST client, call `/api/transfers` with the new filters:

```text
txn_id=<string>
ledger_id=<int>
account_id=<uint128/int>
phase=<int>
status=<int>
state=posted|pending|rejected|rolled_back|expired
sort=batch_seq|timestamp|amount|txn_id|state
order=asc|desc
page=<int>
per_page=<int>
```

The response is grouped at the logical transaction level and includes per-leg
details. This is intentionally separate from the raw TCP SDK so the hot-path
wire protocol remains stable.
