Metadata-Version: 2.5
Name: guveno
Version: 1.4.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 security boundary. Its team scope and the members who hold a sealed copy of the phrase (key access) are set on the vault.
- 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()

# 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. 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.
- **All custodied chains are signable**; Polkadot extrinsics are built fully offline from metadata the server includes in the prepared payload.

## 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`** and **`note`** — **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.flag_count)
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)

# The two review lists, and their counts.
flagged = guveno.client.list_transactions(marked="flagged")
mine = guveno.client.list_transactions(marked="saved")
summary = guveno.client.get_transaction_mark_summary()
print(summary.saved, summary.flagged)
```

Marks are organizational only — they never affect crediting, confirmations or
balances. Reading and writing them 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)
```

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

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

## 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.
