Metadata-Version: 2.5
Name: guveno
Version: 1.7.0
Summary: Add secure crypto custody to your app in minutes — create and manage multi-chain wallets, automate withdrawals, and receive real-time webhooks for Bitcoin, Ethereum, XRP, and Polkadot. Built for exchanges, fintechs, and platforms.
Project-URL: Homepage, https://guveno.com
Project-URL: Documentation, https://guveno.com/docs
Author: Guveno LLC
License-Expression: MIT
Keywords: bitcoin,custody,ethereum,guveno,hd-wallet,polkadot,sdk,tron,wallet,xrp
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Office/Business :: Financial
Classifier: Topic :: Security :: Cryptography
Requires-Python: >=3.10
Requires-Dist: cryptography>=42
Requires-Dist: embit>=0.8
Requires-Dist: eth-account>=0.11
Requires-Dist: httpx>=0.27
Requires-Dist: mnemonic>=0.21
Requires-Dist: pydantic>=2.7
Requires-Dist: substrate-interface>=1.7
Requires-Dist: xrpl-py>=3.0
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Description-Content-Type: text/markdown

# Guveno Wallet SDK for Python

Add secure crypto custody to your platform — create and manage multi-chain wallets, automate withdrawals, and receive real-time webhooks for Bitcoin, Ethereum, XRP, Polkadot, and more. Built for exchanges, fintechs, and platforms.

A typed Python SDK with client-side key encryption and a fully offline signing path, so the server never holds plaintext key material. Fully wire- and key-compatible with the [Node.js SDK](https://www.npmjs.com/package/@guveno/wallet-sdk): the same account derives the same addresses, and secrets sealed by one SDK open in the other.

> Prefer the terminal? The [`@guveno/cli`](https://www.npmjs.com/package/@guveno/cli) package wraps the same API with the same features.

## Install

```bash
pip install guveno
```

Requires Python 3.10+.

## How it works

There are two credentials, and they do different jobs:

- **An API key (`gv_live_...`)** authenticates every request. Generate one in the Guveno dashboard. It carries your company and an explicit set of allowed actions, granted per wallet (plus a couple of global actions). A request for a wallet or action the key wasn't granted is rejected.
- **Your encryption password** decrypts your recovery phrases. The server only ever stores them *sealed* to your account's encryption key; the password (set during dashboard onboarding) unlocks that key locally and **never leaves your process**.

Wallets, addresses, and the sealed secret all live on the server. Listing and reading metadata needs only the API key. Signing (withdrawing, deriving addresses, revealing a phrase) additionally needs your encryption password, because that's what decrypts the key.

Everything is organised as **Vault → Wallet → Address**:

- A **vault** is one recovery phrase: the custody boundary. The members who hold a sealed copy of the phrase (key access) are set on the vault, and that is what decides who can sign.
- A **wallet** is one chain and network inside a vault, at one account-level derivation path (`derivation_path_prefix`, for example `m/44'/60'/0'/0`). Every wallet in a vault derives from the same phrase and inherits the vault's team. A vault holds one wallet per chain, network, and path; a second Ethereum wallet in the same vault needs a different path.
- An **address** is one receiving address of a wallet, at `<prefix>/<account_index>` (`<prefix><account_index>` on Polkadot).

Creating a wallet from a new phrase creates its vault. Adding more chains to that vault reuses the phrase, so one backup covers all of them.

## Quick start

```python
import os
from guveno import Guveno

guveno = Guveno(api_key=os.environ["GUVENO_API_KEY"])

# Browse — metadata only, no password needed.
wallets = guveno.list_wallets()
# Filter with chain/network/vault_id, or resolve ids you already hold
# (up to 100 per call) instead of paging the whole list.
watched = guveno.list_wallets(ids=[12, 7, 3])

# Load a wallet to sign with it: fetches the sealed secret and unlocks it locally.
wallet = guveno.load_wallet(wallets[0].id, os.environ["GUVENO_ENCRYPTION_PASSWORD"])

# Derive and register the next receive address (uses the server-tracked index).
result = wallet.derive_address(label="deposits")
print("Deposit to", result.address["address"])

withdrawal = wallet.withdraw(
    # Omit address_id to auto-select the source: the server picks an address with
    # enough balance — and for Bitcoin aggregates UTXOs across the wallet.
    asset_id=1,
    to_address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
    amount="1.5",
)
print(withdrawal.status)  # 'broadcast' — confirmation arrives via webhooks

wallet.lock()  # wipe key material from memory when done
```

`load_wallet()` accepts a numeric id, a wallet name, or a `{"name", "chain", "network"}` selector:

```python
guveno.load_wallet(wallets[0].id, password)   # by id
guveno.load_wallet("treasury", password)      # by name
guveno.load_wallet({"name": "treasury", "chain": "bitcoin", "network": "mainnet"}, password)
```

Names are only unique within a `(chain, network)`, so a bare name that matches wallets on more than one chain/network raises — pass the scoped selector or the id to disambiguate.

`withdraw()` runs the full `prepare → sign → broadcast` loop: the server returns an unsigned payload, the SDK signs it **in process**, and broadcasts the signed transaction. The recovery phrase never leaves your machine.

By default the source is **auto-selected**. Pass `address_id` to send from one specific address, or (Bitcoin only) `source_address_ids` to restrict UTXO aggregation to a chosen subset of the wallet's addresses. Account-based chains can't combine balances across addresses, so an auto withdrawal raises if no single address covers the amount.

Fees use the server's suggestion by default. Override per call with `ethereum_gas` (an `EthereumGasOverrides`) or `bitcoin_fee_rate` (sat/vB). Bitcoin inputs are fixed at prepare time, so a fee rate much higher than suggested can fail to fit the selected inputs.

## Creating and importing wallets

```python
# Generate a new recovery phrase, derive the first address, seal it, and register it.
created = guveno.create_wallet(
    name="treasury-btc",
    chain="bitcoin",
    network="mainnet",  # mainnet | testnet (Bitcoin) | sepolia (Ethereum) | paseo (Polkadot)
    words=24,           # 12 or 24-word recovery phrase (defaults to 12)
    encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"],
)
print(created.first_address["address"])   # bc1...
print("Back this up:", created.mnemonic)  # shown once — store it safely

# Import an existing phrase as a new server-side wallet.
imported = guveno.import_wallet(
    name="restored-xrp",
    chain="xrp",
    network="mainnet",
    mnemonic="test test test test test test test test test test test junk",
    encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"],
)
```

Both return a `Wallet` that's already loaded and ready to use. A new phrase creates a new vault, named after the wallet unless you pass `vault_name`; `team_id` scopes the vault to a team (omit it to inherit your own team). Importing a phrase that already backs one of your vaults adds the wallet to that vault instead, provided you hold key access to it.

### Vaults: one phrase, many chains

```python
# Browse vaults (metadata only).
for vault in guveno.list_vaults():
    print(vault.name, vault.fingerprint[:8], vault.wallet_count, vault.access)

# Add another chain to an existing vault. The phrase is fetched sealed from the
# server and unlocked locally; nothing about the key is sent.
eth = guveno.add_wallet_to_vault(
    created.wallet.detail.vault_id,
    name="treasury-eth",
    chain="ethereum",
    network="mainnet",
    encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"],
)

# A second Ethereum wallet in the same vault needs its own derivation path.
ops = guveno.add_wallet_to_vault(
    created.wallet.detail.vault_id,
    name="ops-eth",
    chain="ethereum",
    network="mainnet",
    derivation_path_prefix="m/44'/60'/1'/0",
    encryption_password=os.environ["GUVENO_ENCRYPTION_PASSWORD"],
)
```

Every wallet record carries `vault_id`, `vault_name`, `team_id`, and `derivation_path_prefix`. `list_wallets(vault_id=...)` lists one vault's wallets, and `list_wallets(ids=[...])` resolves a set of ids you already hold (at most 100 per call). A custom prefix must match the chain's grammar (BIP32 with `'` for hardened segments; `//name//` junctions on Polkadot), and the server rejects addresses that are not derived at exactly the wallet's prefix, so keep using `derive_address()` rather than registering addresses by hand.

## Using a loaded wallet

```python
wallet = guveno.load_wallet(wallet_id, encryption_password)

wallet.withdraw(asset_id=..., to_address=..., amount=...)                  # auto-select source
wallet.withdraw(address_id=..., asset_id=..., to_address=..., amount=...) # specific source
next_addr = wallet.derive_address(label="deposits")  # next address at the server index
page = wallet.list_addresses()
balances = wallet.get_balances()      # per-address, per-asset
totals = wallet.get_totals()          # aggregated per asset
stats = wallet.get_stats()            # holdings, flows, top addresses
phrase = wallet.reveal_mnemonic()     # audited server-side
status = wallet.get_withdrawal(withdrawal.id)

wallet.id; wallet.name; wallet.chain; wallet.network  # metadata properties
```

- **Per-source serialization** — withdrawals from the same source (a specific address, or a wallet when auto-selecting) are queued so concurrent sends never collide on a nonce or reuse a UTXO; different sources run in parallel.
- **Idempotency** — an `idempotency_key` is auto-generated per withdrawal; pass your own to make a retried `withdraw(...)` replay-safe. Without one, a retry prepares a *new* withdrawal.
- **All custodied chains are signable**; Polkadot extrinsics are built fully offline from metadata the server includes in the prepared payload.

### When a send's outcome is unknown

A broadcast is the one call here whose failure does not mean "nothing happened".
If the node accepted the transaction and the reply was lost, the payment is out
there. The SDK raises a distinct error for that case so you never mistake it for
a refusal:

```python
from guveno import SendOutcomeUnknownError

try:
    wallet.withdraw(address_id=..., asset_id=..., to_address=..., amount=...)
except SendOutcomeUnknownError as error:
    # The transaction MAY be live. Do not sign a replacement.
    alert_ops(error.withdrawal_id, error.tx_hash)
```

What to do with one:

- Look up `error.tx_hash` on the chain. If it is there, the withdrawal settles by
  itself and `wallet.get_withdrawal(id)` will show it reach `confirmed`.
- Or call `guveno.client.broadcast_withdrawal(...)` again with the **identical**
  `signed_raw_tx`. The same bytes are the same transaction, so resending them is
  not a second payment — and the server accepts only those bytes for this
  withdrawal.

What not to do: prepare again, sign again, or retry `withdraw()`. On an account
chain a replacement is signed at the next nonce, so if the first transaction did
land you have paid twice. The server enforces this too — until the send is
resolved, preparing any new withdrawal from that address fails with a conflict,
and the broadcast endpoint refuses any signed transaction other than the one it
already holds.

## Headless signing (KMS / HSM / file)

For automated signers that hold their own key material, load a wallet with a key provider instead of a password — no encryption password, and the sealed secret is never fetched from the server:

```python
from guveno import Guveno, FileKeyProvider, KmsKeyProvider

guveno = Guveno(api_key=os.environ["GUVENO_API_KEY"])

# keys.json: { "<keyFingerprint>": "<bip39 mnemonic>", ... }
wallet = guveno.load_wallet(wallet_id, FileKeyProvider("/run/secrets/keys.json"))
wallet.withdraw(address_id=100, asset_id=1, to_address="0x70997...", amount="1.5")
```

Don't want a plaintext key file? Back it with AWS KMS, GCP KMS, Vault, or an HSM — the plaintext mnemonic exists only transiently in memory while a withdrawal is signed:

```python
def unwrap(ciphertext: str, key_fingerprint: str) -> str:
    out = kms.decrypt(CiphertextBlob=base64.b64decode(ciphertext))
    return out["Plaintext"].decode("utf-8")  # the mnemonic

keys = KmsKeyProvider(entries={"<keyFingerprint>": "<base64 KMS ciphertext>"}, decrypt=unwrap)
wallet = guveno.load_wallet(wallet_id, keys)
```

Key-provider wallets can `withdraw()` and `list_addresses()`; `derive_address()` and `reveal_mnemonic()` need the encryption-password path (they seal/unseal against the server).

## Balances

Reading balances needs only the API key — no encryption password or key provider. The quickest path is the getters on a loaded wallet, but `guveno.client` exposes the same endpoints if you only have a wallet id:

```python
# Per-address, per-asset balances — one entry per address.
for addr in wallet.get_balances():
    for b in addr.balances:
        print(addr.address, b.asset.symbol, b.total)

# Aggregated per-asset totals across the wallet's addresses.
for t in wallet.get_totals():
    print(t.asset.symbol, t.total)

# Holdings, lifetime in/out flows, and the top-holding addresses.
stats = wallet.get_stats()
print(stats.address_count, stats.transaction_count)
```

`total`/`amount` are decimal strings in the asset's main unit (e.g. `"1.5"` ETH), and each carries the full `asset` (symbol, decimals, contract address, …) so you don't need a separate asset lookup.

Without a loaded wallet, call the client directly — and roll up the whole company with `get_company_balance_summary`:

```python
totals = guveno.client.get_wallet_balance_totals(wallet_id)

# Company-wide totals per asset, optionally scoped to a chain/network.
company = guveno.client.get_company_balance_summary()
eth_only = guveno.client.get_company_balance_summary(chain="ethereum", network="mainnet")
```

## Transactions and review marks

Transactions come off the client, newest first, with the usual filters and cursor
pagination:

```python
deposits = guveno.client.list_transactions(wallet_id=wallet_id, direction="incoming", limit=50)
page = guveno.client.list_transaction_page(chain="bitcoin", status="confirmed")
one = guveno.client.get_transaction(9001)
```

Each transaction carries a `marks` object — the review layer your team uses in the
dashboard. There are two marks and they differ in **who can see them**:

- **★ `saved`** — **personal**. Your own "review later" queue. Another member's saved
  state is never reported to you, and yours is never reported to them.
- **⚑ `flagged`**, **`note`** and the **assignee** — **shared, with the author
  attached**. A flag says "this needs attention", so everyone who can already see the
  transaction sees who raised it and why.

`saved` / `flagged` / `note` at the top level are *yours* (an API key acts as its
creator); `shared` lists every member's flag and note with its author:

```python
tx = guveno.client.get_transaction(9001)
print(tx.marks.saved, tx.marks.open_flags)
for mark in tx.marks.shared:
    print(mark.user.email, "flagged" if mark.flagged else "noted", mark.note)

# Only the arguments you pass change — flagging leaves the ★ and the note alone.
guveno.client.set_transaction_marks(9001, flagged=True, note="unexpected fee")
# `note=None` means "leave it alone", so clearing is explicit:
guveno.client.set_transaction_marks(9001, clear_note=True)
```

### A flag's lifecycle

A flag is raised, then either **resolved** (reviewed and closed) or withdrawn
(`flagged=False`, as if it had never been raised) — which is what makes the flagged
list a queue rather than a pile. It may also name one member it is waiting on, who is
notified once, in the dashboard and by email.

```python
# Hand it to someone: they must already be able to see the transaction.
guveno.client.set_transaction_marks(9001, flagged=True, assign_to_user_id=8)
guveno.client.set_transaction_marks(9001, unassign=True)   # clear the assignee

# Closing is a separate call, because the reviewer is usually not the raiser.
guveno.client.resolve_transaction_flag(9001, raised_by_user_id=3)
guveno.client.resolve_transaction_flag(9001, raised_by_user_id=3, resolved=False)  # re-open

# The review lists, and their counts.
waiting = guveno.client.list_transactions(marked="flagged_open")
mine = guveno.client.list_transactions(marked="assigned")
summary = guveno.client.get_transaction_mark_summary()
print(summary.saved, summary.open_flags, summary.waiting_on_me)
```

Marks are organizational only — they never affect crediting, confirmations or
balances. Reading and writing them, resolving another member's flag included, needs
only the `transactions:read` action.

## Webhooks and low-level access

The high-level facade covers wallet and vault operations. For everything else (webhooks, renaming or deleting wallets, listing withdrawals, vault administration) use the underlying client at `guveno.client` (a `GuvenoApiClient`):

```python
webhook = guveno.client.create_webhook(
    type="api",
    config={"url": "https://example.com/webhooks/guveno"},
    events=["deposit.confirmed", "withdrawal.confirmed"],  # or ["*"] for all
)
print(webhook.signing_secret)  # shown only here — store it to verify x-guveno-signature

deliveries = guveno.client.list_webhook_deliveries(webhook.id, limit=50)

# One delivery in full: `payload` is the exact body posted (the bytes the
# signature covers) and `response_body` is what your endpoint replied.
delivery = guveno.client.get_webhook_delivery(webhook.id, deliveries[0].id)
```

All event types are exported as `WEBHOOK_EVENT_TYPES`. Managing webhooks requires an API key with the `webhooks:manage` action.

### Addresses without the seed

`wallet.derive_address()` derives locally, so it needs the encryption password. A
wallet that has registered its account xpub (`wallet.enable_server_derivation()`)
can have the server derive instead, from an API key alone:

```python
address = guveno.client.generate_address(wallet_id, label="customer-4471")

# Pin the index to give one customer the same address on two wallets that share
# a vault and a derivation prefix, and so an account xpub.
matched = guveno.client.generate_address(
    bsc_wallet_id,
    label="customer-4471",
    account_index=address.account_index,
)
```

Omit `account_index` and the server allocates the wallet's next one, so two
concurrent calls get two consecutive addresses. Pass one and an index already
registered on that wallet is refused with a 409, archived addresses included:
nothing is reassigned and no free index is substituted, because the address on a
taken index may already belong to someone else. Allocation stays "highest + 1",
so pinning a high index moves the wallet's next automatic index above it. Valid
indexes run from 0 to `MAX_ACCOUNT_INDEX`. Needs the `wallets:generate_address`
action; Polkadot cannot derive from a public key and is refused.

Vault administration lives on the client too: `create_vault()` registers a phrase without a wallet yet (needs `wallets:create`), while `update_vault()` (rename) and `delete_vault()` (archive; refused while it has active wallets or funded addresses) are accepted from dashboard user sessions only, because each one changes who can see every wallet in the vault. The server rejects API keys on those routes.

Who holds a copy of a vault's phrase has no SDK surface at all. Passing key material to another member decrypts the sharer's own copy in their browser, re-seals it to the recipient, and waits for the recipient to accept — a dashboard ceremony, and the only way a copy is ever made. Do it under **Vault → Key holders**.

## Supported chains

| Chain | Address style |
| --- | --- |
| Bitcoin | native SegWit `bc1...` |
| Ethereum | `0x...` |
| BNB Smart Chain | `0x...` (same address as Ethereum) |
| XRP | classic `r...` |
| Polkadot | SS58 (`1...` mainnet) |
| TRON | Base58Check `T...` |

**We're adding new chains regularly** — these six are live today. All use standard HD derivation, so a recovery phrase restores the same accounts in any compatible wallet (Polkadot uses sr25519 substrate junctions, matching Polkadot-JS / Talisman; BSC shares Ethereum's derivation, matching MetaMask/Trust Wallet).

### Optional key file

Enable **Also require a key file** during dashboard encryption setup or under
**Profile → Wallet encryption**. Setup requires a fresh test unlock before saving.
The file can be any format, including an image (non-empty, up to 20 MiB).
Use its exact original bytes: copying or renaming is fine; edits, image compression,
and metadata changes prevent unlocking. Keep a separate backup. Public/shared files
provide little additional protection. A generated random file is also available.

Once enabled, both your encryption password and the file are required to unlock
and recover wallet secrets through Guveno. Neither input is uploaded or stored by
the SDK. Guveno cannot reset or recover them. An independently backed-up wallet
recovery phrase can still recover that wallet. This setting protects your access
across all wallets; other authorized members retain their own access.
Old encrypted backups retain their old credentials, and keys already unlocked in
another process remain usable until locked. Changing protection keeps wallet
addresses and recovery phrases unchanged.

```python
from pathlib import Path
key_file = Path('/secure/original-photo.png').read_bytes()
wallet = guveno.load_wallet(wallet_id, encryption_password, key_file=key_file)
# Use wallet, then wallet.lock() when finished.
del key_file  # Python cannot guarantee wiping immutable bytes from memory.
```

`create_wallet()` and `import_wallet()` also accept `key_file: bytes` alongside
`encryption_password`. `EncryptionSession.unlock()` / `unlock_from_user()` and
`decrypt_user_private_key()` accept `key_file` as their third argument. Omit it for
password-only accounts; an unexpected file or combination with a key provider fails.
