The Varnir SDK for Python
varnir is the Python port of @varnir/chain-client. Shared rules (amounts,
field order, $sigs, send results, recovery phrases, golden vectors) are on
SDK overview and backends.
Canonical JSON, transaction building and all signing come from
Activeledger's Python SDK; the package contains no canonical.py, no
pq.py and no native code. See
Cryptography comes from Activeledger.
Install
Activeledger's SDK is on PyPI as activeledger (1.4.0 and later), and pip
fetches it for you:
pip install git+https://github.com/Varnir/varnir.git#subdirectory=packages/sdk-python
Post-quantum identities (Falcon-512, ML-DSA-65) need one extra:
pip install 'varnir[pq] @ git+https://github.com/Varnir/varnir.git#subdirectory=packages/sdk-python'
liboqs-python does not bundle liboqs; it builds it from source on first
import, which needs git, CMake, a C compiler and OpenSSL headers. Activeledger
makes it an extra and this package mirrors that. Classical (secp256k1)
identities work without it.
First call
from varnir import VarnirClient
# The identity id is DERIVED from the public key, not looked up - so this
# needs no network call and works before the identity exists on the ledger.
client = VarnirClient.for_public_key("0x02a1633cafcc01eb...")
for balance in client.get_balances():
print(balance.network, balance.token, balance.raw, balance.amount)
With a key, to move money:
from varnir import VarnirClient
client = VarnirClient.onboard(private_key) # idempotent
result = client.send(chain="niles", amount="1.5", to="TSC9KBw...")
if result.settled:
print("sent:", result.umid)
else:
# Over the key's co-sign threshold. NO MONEY HAS MOVED.
print("held for a co-signature:", result.proposal.id)
Language-specific notes
send returns a SendResult; check result.settled / result.held
(why).
Sending with a speed
An L1 withdrawal takes an optional speed: one of PAYOUT_SPEEDS
("economy", the default; "standard"; "instant"), also named as
PayoutSpeed.ECONOMY and so on.
from varnir import PayoutSpeed
result = client.send(chain="niles", amount="1.5", to="TSC9KBw...", speed=PayoutSpeed.INSTANT)
print(result.speed) # 'instant'; 'economy' if omitted; None off L1
print(result.speed_recorded) # None unless speed was passed explicitly
Payout speeds are not built on the network yet: every withdrawal still goes
out immediately at the Standard rate, and the choice is only recorded. It is
never part of the signed $tx. An explicit speed is posted afterwards to
POST /api/withdrawals/speed, signed with the identity's own key and
best-effort. That call never raises, so speed_recorded == False does not
mean the send failed. An unknown value raises VALIDATION before anything is
sent.
Verifying webhooks
No client needed, just the webhook secret. Pass the raw body, before parsing JSON:
import json
from varnir import verify_webhook_request
def handle(secret: str, raw_body: bytes, headers) -> dict:
# headers: a dict, or Flask/Starlette/Django request.headers
if not verify_webhook_request(secret, raw_body, headers):
raise PermissionError("not a genuine, recent Varnir delivery")
return json.loads(raw_body)
It reads X-Varnir-Timestamp and X-Varnir-Signature case-insensitively.
It returns False (never raises) on a missing header, a signature without
v1=, a timestamp more than 300 seconds either side of your clock, or a
mismatch. verify_webhook_signature(secret, body, timestamp, signature) takes
the two values directly.
Float arguments are refused
from varnir import to_raw_amount
to_raw_amount("1.5", 6) # '1500000'
to_raw_amount("1.5", 18) # '1500000000000000000'
to_raw_amount("0.1", 0) # ValueError: rounds to zero
to_raw_amount(1.5, 6) # TypeError: pass amounts as strings
A float argument raises TypeError rather than being coerced. See
Amounts are strings.
Two expiry rules that are opposites
key.is_expired # no `expire` -> False (never expires)
proposal.is_expired # no `expiresAt` -> True (already expired)
The reasons, and why they must not be reconciled, are in Two expiry rules.
Committed but not confirmed: OUTCOME_UNKNOWN
send can raise VarnirError after the transaction already committed: if
the stream it created can't be read back in time (read-after-write lag, more
likely under Activeledger v5's P2P commit), the SDK doesn't know whether it
moved money or is held for co-sign. That's VarnirErrorCode.OUTCOME_UNKNOWN
— never resend. Confirm first:
from varnir import VarnirError, VarnirErrorCode
try:
result = client.send(chain="niles", amount="1.5", to="TSC9KBw...", ref="invoice-42")
except VarnirError as err:
if err.code == VarnirErrorCode.OUTCOME_UNKNOWN:
print("commit unknown:", err.committed_umid)
# err.message names the created stream to check:
# client.get_transfer_proposal(stream_id)
# or look the committed umid up in the transaction history. Do not resend.
else:
raise
See the error taxonomy for the full table.
Invoices
An invoice is paid into wallets you own. Give it existing ones, or a new wallet of its own on each chain. Nobody has paid into a new wallet before, so a payment into it can only be for this invoice. A native-coin invoice takes one chain; USDT can take several. The new wallets are yours to keep and count towards your plan's wallet limit. They are priced in credits as ordinary wallets; see How credits work.
from varnir import CreateInvoiceNewWallets
# Existing wallets.
wallet = client.get_wallet("niles")
invoice = client.create_invoice(wallet_ids=[wallet.id], name="Order 1042", amount="25")
# A new wallet for this invoice on each chain.
invoice = client.create_invoice(
new_wallets=CreateInvoiceNewWallets(chains=["sep", "niles"]),
name="Order 1043",
amount="25",
currency="USDT",
)
for w in invoice.wallets:
print(w.chain, w.address)
print(invoice.url)
What it covers
Feature parity with @varnir/chain-client — 47 public methods against its 45,
with 146 tests.
| Identity | derive, onboard (idempotent), read the stream |
| Money | balances, send, transfer_on_ledger, held-proposal detection |
| Keys | add/remove, roles, scopes, spend policy, expiry, thresholds |
| Treasury | pending approvals, watch, confirm, void |
| Wallets | list, get, create, L1 balances, consolidate |
| Commerce | invoices, webhooks, deposits, receipts, transactions |
| Exchange | orders, partial fills, market orders, matching, co-signed swaps |
| Transport | gateway failover, and the commit-decides submission rule |
Where each method runs is on Where each method runs.
list_transactions and list_wallets go to any gateway; only invoices and
webhooks need the scanner's API origin.
Not reported: reserved / available on a balance. They depend on this
identity's open resting orders, and reporting available == raw would be wrong
for anyone holding one, so they are omitted rather than guessed.
Recovery phrases: whole for secp256k1, seed only for post-quantum
derive_key_pair_from_phrase(phrase, 'secp256k1') returns the key pair, using
upstream's derivation (Recovery phrases).
derive_seed_from_phrase returns the algorithm seed for Falcon-512 and
ML-DSA-65, and derive_key_pair_from_phrase raises for them. liboqs exposes
no derandomised signature keygen — OQS_SIG_keypair takes no seed, and
_keypair_derand is KEM-only — so there is nothing for the binding to wrap.
Lifting this limit requires moving off liboqs.
The seed is portable: hand it to the .NET, Kotlin or TypeScript SDK and you get the same Varnir identity. Generating, signing and verifying post-quantum keys all work here; only seeded generation does not.
Bitcoin addresses
Check a Bitcoin address before paying it. Networks are btc, btc-testnet4 and btc-regtest; anything else, an address for the other network, a bad checksum or a witness version we do not pay is refused with VALIDATION. Bech32 comes back lower-case. Amounts need nothing new: Bitcoin is 8 decimal places, so "0.0001" is "10000" satoshis, and send() returns the ledger umid as on every chain (core contract section 5.1). send() itself refuses a destination that is not valid for the requested network (EVM with its EIP-55 checksum, Tron, Bitcoin) with VALIDATION, before the Varnir-known lookup and before anything is signed (section 5.2). The cases are generated from packages/btc-core.
from varnir import is_valid_btc_address, normalise_btc_address, network_family
is_valid_btc_address("btc-testnet4", "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx") # True
normalise_btc_address("btc-testnet4", "TB1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KXPJZSX") # lower-case
network_family("btc-testnet4") # "bitcoin", even while the network is disabled
Tests
The test vectors are generated from the TypeScript client and core.json
lives in this package; see Golden vectors for the
generation command and what they have caught.