A modern, async-first Python client library for the Tari L2 network
(codename Ootle). It is the Pythonic counterpart to the Rust crate
ootle-rs and the JavaScript package @tari-project/ootle-wasm.
Status: pre-1.0. The
0.x.yAPI may shift; every breaking change is recorded inCHANGELOG.md.
pip install ootle-py
# or
uv add ootle-pyThe distribution name on PyPI is ootle-py; you import it as ootle:
from ootle import AsyncOotleClient, OotleClientRequirements: Python >= 3.13. The package is pure-Python; the Tari
WASM crypto blob ships inside the wheel — no native build step, and the
same py3-none-any wheel runs everywhere.
Connect to a LocalNet indexer and read an account balance. No wallet is needed for read-only queries.
import asyncio
from ootle import (
AsyncOotleClient, ComponentAddress, Network,
ResourceAddress, TARI_TOKEN, default_indexer_url,
)
async def main() -> None:
account = ComponentAddress("component_...your-address...")
async with AsyncOotleClient.connect(default_indexer_url(Network.LOCAL_NET)) as client:
balance = await client.get_account_balance(account, ResourceAddress(TARI_TOKEN))
print(f"TARI balance: {balance}")
for resource, amount in (await client.get_account_balances(account)).items():
print(f" {resource}: {amount}")
asyncio.run(main())Full version: examples/balance_query.py
(and the sync mirror, examples/balance_query_sync.py).
Faucet a fresh sender, then transfer TARI to a fresh recipient, watching
each transaction to finalisation. Note that seal_transaction is
synchronous — only the I/O calls are awaited.
import asyncio
from ootle import (
AsyncOotleClient, LocalSigner, Network, OotleSecretKey,
OotleWallet, ResourceAddress, TARI, TARI_TOKEN, default_indexer_url,
)
async def main() -> None:
sender = OotleSecretKey.random(Network.LOCAL_NET)
wallet = OotleWallet(default=LocalSigner(sender))
resource = ResourceAddress(TARI_TOKEN)
async with AsyncOotleClient.connect(
default_indexer_url(Network.LOCAL_NET), wallet=wallet
) as client:
# Faucet the public XTR faucet's fixed dispense into the sender.
funded = await client.faucet().take_funds().pay_fee(500).prepare()
await (await client.send_transaction(client.seal_transaction(funded))).watch()
# Transfer 2 TARI to a fresh recipient.
recipient = OotleSecretKey.random(Network.LOCAL_NET).to_address()
unsigned = await (
client.account()
.pay_fee(1000)
.public_transfer(recipient, resource, 2 * TARI)
.prepare()
)
sealed = client.seal_transaction(unsigned)
await (await client.send_transaction(sealed)).watch()
asyncio.run(main())Register extra signers with wallet.register(LocalSigner(...)) and they
are folded in automatically at seal time (multi-signer co-authorisation);
manual_co_signing.py shows the explicit authorize → attach → seal
hand-off for remote-signer / HSM setups. Estimate fees first with
await client.send_dry_run(unsigned). Full file:
examples/fungible_transfer.py.
Confidential transfers run entirely on the vendored ootle-wasm
blob — the default crypto provider. Bulletproofs, ElGamal viewable
balances, balance-proof signatures, and input-mask aggregation all
execute locally; no wallet daemon or external service is required.
Build the transfer, hydrate the balance proof with an
AsyncWalletStealthAuthorizer, then seal with the wallet's default
signer:
import asyncio
from ootle import (
AsyncOotleClient, AsyncStealthTransfer, AsyncWalletStealthAuthorizer,
LocalSigner, Network, OotleSecretKey, OotleWallet, Output,
ResourceAddress, TARI, TARI_TOKEN, default_indexer_url,
)
async def main() -> None:
sender = OotleSecretKey.random(Network.LOCAL_NET)
wallet = OotleWallet(default=LocalSigner(sender))
resource = ResourceAddress(TARI_TOKEN)
recipient = OotleSecretKey.random(Network.LOCAL_NET).to_address()
async with AsyncOotleClient.connect(
default_indexer_url(Network.LOCAL_NET), wallet=wallet
) as client:
# ... faucet the sender first (see examples/stealth/) ...
transfer = AsyncStealthTransfer(client, resource)
transfer.spend_revealed_input(sender.to_address().to_component_address(), 5 * TARI)
transfer.to_stealth_output(
Output(destination=recipient, amount=4 * TARI, resource_address=resource)
)
transfer.to_revealed_output(TARI)
transfer.pay_fee_from_revealed(500)
spec = await transfer.prepare()
authorizer = AsyncWalletStealthAuthorizer(
wallet, spec, view_secret=sender.view_secret
)
hydrated = await authorizer.prepare(client)
sealed = client.seal_transaction(hydrated.unsigned)
await (await client.send_transaction(sealed)).watch()
asyncio.run(main())Stealth supports revealed and stealth inputs and outputs, mixed in one
transfer: spend_revealed_input / spend_stealth_input (with input-mask
aggregation) feed to_stealth_output / to_revealed_output, with fees
paid from a revealed bucket (pay_fee_from_revealed) or a stealth
account's revealed vault (pay_fee_from_stealth). The public faucet has
a stealth path too (IFaucet.take_funds_stealth), and owned UTXOs are
read back with AsyncOotleClient.decrypt_owned_utxo (AEAD owner-read).
The full set of runnable stealth examples — faucet deposit,
stealth↔revealed, stealth↔stealth, spending an owned UTXO, and the sync
mirror — lives in examples/stealth/.
Every async API has a sync mirror with the same shape. Swap the imports
and drop the awaits:
from ootle import OotleClient # sync
from ootle import AsyncOotleClient # asyncThe sync names mirror the async ones — IAccount / IAsyncAccount,
StealthTransfer / AsyncStealthTransfer, PendingTransaction /
AsyncPendingTransaction, and so on. The sync tree is generated from
the async source by scripts/unasync.py and
committed; CI asserts the two trees stay byte-identical. Both ship in the
wheel.
The examples are self-contained — each generates fresh keys and faucets its own funds against a LocalNet indexer:
OOTLE_INDEXER_URL=http://localhost:12500 \
uv run python -m examples.fungible_transferSee examples/README.md and
examples/stealth/README.md for the full
catalogue and the few examples that need an external artifact (a deployed
template, a live component to watch, …).
Every internal module emits diagnostics under the ootle.* logger
namespace. The library installs a NullHandler on ootle so
unconfigured callers see nothing; opt in by configuring logging:
import logging
logging.basicConfig(level=logging.INFO)
logging.getLogger("ootle").setLevel(logging.DEBUG)Useful sub-namespaces:
ootle._async._transport/ootle._sync._transport— request paths.ootle._async._watcher/ootle._sync._watcher— SSE open/close, fallbacks.ootle._async._resolver/ootle._sync._resolver— chunked substate fetches.ootle._crypto._wasm_runtime— WASM blob load + SHA-256 verification.ootle.wallet— multi-signer co-authorisation traces.
In v1: read-only queries · the public XTR faucet · public transfers
(IAccount) · multi-signer co-authorisation · the untyped component DSL
(IComponent.call_function / call_method) · dry-run fee estimation ·
transaction and component event watching · confidential stealth
transfers (revealed + stealth inputs/outputs, input-mask aggregation,
the faucet stealth path, and UTXO read helpers), all backed by the
vendored WASM blob.
Not in v1:
- Typed templates /
ootle_template!— only the untypedIComponentpath ships; there is no codegen for typed bindings. - HD wallets, BIP-32, mnemonics — caller-application concerns.
- Borsh in Python — all Borsh encoding/decoding is delegated to the WASM blob, never reimplemented in Python.
The engineering contract — including the 200-line file limit and the
strict typing rules — is in CLAUDE.md.
make sync # bootstrap the venv
make ci # the gate the pipeline runs
make unasync # regenerate src/ootle/_sync/ from _async/After any change under src/ootle/_async/, run make unasync and commit
the regenerated _sync/ mirror in the same commit — make ci fails on
drift.
Released under the BSD 3-Clause license — see LICENSE.
The vendored WASM blob is built from
@tari-project/ootle-wasm
and ships inside the wheel under
src/ootle/_crypto/wasm/ootle_wasm_bg.wasm, with its upstream version
and SHA-256 recorded in VERSION and verified at load.