Skip to main content

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'
Why post-quantum is opt-in

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.

Identityderive, onboard (idempotent), read the stream
Moneybalances, send, transfer_on_ledger, held-proposal detection
Keysadd/remove, roles, scopes, spend policy, expiry, thresholds
Treasurypending approvals, watch, confirm, void
Walletslist, get, create, L1 balances, consolidate
Commerceinvoices, webhooks, deposits, receipts, transactions
Exchangeorders, partial fills, market orders, matching, co-signed swaps
Transportgateway 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).

Post-quantum phrases stop at the seed

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.