Metadata-Version: 2.4
Name: paymos
Version: 0.1.3
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Rust
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: Microsoft :: Windows
Classifier: License :: Other/Proprietary License
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Office/Business :: Financial
Classifier: Typing :: Typed
Requires-Dist: httpx>=0.27
Summary: Non-custodial, headless SDK to control a wallet vault: balances, fee-transparent quotes, swaps and on-chain withdrawals over a co-signed 2-of-2 vault.
Keywords: wallet,vault,crypto,payments,sdk,non-custodial,mpc,withdrawals
Author: Paymos
License: Proprietary
Requires-Python: >=3.11
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Homepage, https://wallet.paymos.io

# paymos — Python SDK

Non-custodial, headless control of a wallet vault: read balances, preview fee-transparent
quotes, and move money with `swap` and `withdraw` — all over a co-signed **2-of-2** vault.

Your process holds **one half** of the signing key; the Paymos server holds the other.
**Neither side alone can move funds** — every swap and withdrawal is co-signed, and your half
never reaches the server.

- **Non-custodial** — a leaked API key can't sign; your co-signing share stays in your process.
- **Fee-transparent** — every quote is a dry preview with a full `platform / network / route`
  fee breakdown you see *before* signing anything.
- **Human amounts, no floats** — you pass decimal strings like `"100"` / `"0.5"`; responses come
  back as raw integer strings.
- **No Rust toolchain** — the native signing core ships compiled inside the wheel.
- **Async-first** — built on `httpx.AsyncClient`, fully typed (`py.typed`).

The SDK talks in plain asset terms (`USDC@base`, `ETH@arb`) and hides the routing and the
2-of-2 signing behind a small, safe surface.

## Install

```bash
pip install paymos
```

Requires **Python ≥ 3.11**. The only runtime dependency is `httpx` (installed automatically).

Prebuilt wheels cover **Windows**, **macOS** (Intel + Apple Silicon), and **Linux** (x86-64 and
ARM, glibc and musl). Each `cp311-abi3` wheel serves CPython **3.11 / 3.12 / 3.13+**, so a single
wheel per platform is all you need — and there's no compiler step on install.

## Get a vault secret

The SDK is driven by one `vs_live_…` **vault secret**, minted in the **wallet Mini App →
Settings → Vault API**. There are two kinds:

| Key | Can do | Carries |
|---|---|---|
| **read** | `assets`, `balances`, `quote_swap`, `quote_withdraw`, `movement`, `movements` | API key only |
| **full** | everything a read key does, **plus** `swap` and `withdraw` | API key **+** your co-signing share |

The share is decrypted on your device and **never leaves your process**. There is no CLI
provisioner — the secret is minted in the Mini App. Treat a full secret like a private key.

Point the SDK at it with an environment variable:

```bash
export PAYMOS_VAULT_SECRET="vs_live_…"
# optional — defaults to https://wallet.paymos.io
# export PAYMOS_BASE_URL="https://wallet.paymos.io"
```

## Quickstart

```python
import asyncio
from paymos import Wallet

async def main():
    async with Wallet.from_env() as w:              # reads PAYMOS_VAULT_SECRET
        # Balances: per-asset, raw amount strings + a nullable USD value.
        for b in await w.balances():
            print(b.asset, b.amount_raw, b.usd)

        # Preview a withdraw — a dry, money-safe quote. Headline is the fee breakdown.
        q = await w.quote_withdraw(asset="USDC@base", amount="25", to="0xRecipient…")
        print(q.fees.platform, q.fees.network, q.fees.total)

        # Move money (full key only). Amounts are human decimal strings.
        mv = await w.swap(send="USDC@base", receive="ETH@arb", amount="100")
        settled = await w.wait(mv.id)               # poll to a terminal status
        print(settled.status)

asyncio.run(main())
```

`Wallet` is an async context manager (`aclose()` releases the HTTP client). You can also build it
directly — `Wallet("vs_live_…")` — or from the environment with `Wallet.from_env()`.

## Core concepts

### Amounts: human in, raw out

Amounts you **pass** are human decimal strings (`"100"`, `"0.5"`) — the server owns each asset's
decimals and scales them to raw. Amounts in **responses** (balances, quote legs, fees) come back
as raw integer strings. **Never a float**, in either direction.

### Assets

Assets use white-label `SYMBOL@chain` ids (`USDC@base`, `ETH@arb`). `assets()` returns the catalog
(fetched once and cached per `Wallet`); the SDK uses each asset's `decimals` to validate the
amounts you pass — an unknown asset or an over-precise amount raises `PaymosError` locally, before
anything hits the wire.

### Quotes and fees

`quote_swap` / `quote_withdraw` always return a **dry** `Quote` — a pure preview that persists
nothing and works for read and full keys alike. The headline is `quote.fees`:

```python
q = await w.quote_swap(send="USDC@base", receive="ETH@arb", amount="100")
print(q.fees.platform)   # raw string
print(q.fees.network)    # raw string
print(q.fees.route)      # RouteFee(amount, estimate) — or None when there's no route leg
print(q.fees.total)      # raw string
print(q.receive.amount)  # expected out
print(q.receive.min)     # guaranteed out (your slippage floor)
```

### Moving money

`swap` and `withdraw` (full key only) create a **real** movement and drive the 2-of-2 co-sign,
then return a `Movement`. Before any signature the SDK re-checks that the server's quote echoes
**exactly** the assets and amount you approved — a decimals bug, contract drift, or a lying server
raises and **signs nothing**.

```python
# Swap, delivered back to your own vault.
mv = await w.swap(send="USDC@base", receive="ETH@arb", amount="100",
                  min_receive="0.03")               # optional slippage floor

# Withdraw to an external chain address.
mv = await w.withdraw(asset="USDC@base", amount="25", to="0xRecipient…")
```

### Withdraw modes

- `mode="exact_out"` *(default)* — the recipient receives exactly `amount`; the vault debit
  (`amount` + fees) is server-computed.
- `mode="total_in"` — `amount` is the **total** debited from the vault; the recipient gets that
  minus fees.

### Safety caps

- **`slippage_bps`** (swap / quote_swap, default `50` = **0.5%**) — the slippage tolerance sent with
  the quote in basis points; the quote's guaranteed `receive.min` already reflects it.
- **`min_receive`** (swap) — a human decimal in the receive asset. If the quote's *guaranteed*
  receive is below it, raises `SlippageExceeded` and signs nothing.
- **`max_debit`** (swap / withdraw) — a human decimal ceiling on the total vault debit. Strongly
  recommended for unattended `exact_out` payouts, whose input side is server-computed. Exceeded →
  raises, signs nothing.

### Idempotency

Pass your own `idempotency_key` (e.g. a durable payout id) so a retry after an ambiguous failure
replays the **same** movement instead of paying twice; a retry that finds it already
signed/relayed **converges** on it rather than double-signing. Omit it and a fresh key is generated
per call.

```python
mv = await w.withdraw(asset="USDC@base", amount="25", to="0xRecipient…",
                      idempotency_key="payout-8f21", max_debit="26")
```

### Waiting and statuses

`wait(id)` polls a movement until it reaches a terminal status —
`completed | failed | refunded | expired | cancelled` — and raises `PaymosError` on timeout.

```python
mv = await w.wait(mv.id, timeout=120, poll=2.0)
```

## API reference

All `Wallet` methods are `async`.

| Method | Returns | Notes |
|---|---|---|
| `Wallet(secret, base_url=…, *, timeout=None)` | `Wallet` | `secret` is a `vs_live_…` string |
| `Wallet.from_env(base_url=None, *, timeout=None)` | `Wallet` | reads `PAYMOS_VAULT_SECRET` / `PAYMOS_BASE_URL` |
| `assets()` | `list[Asset]` | curated catalog, cached |
| `balances()` | `list[Balance]` | per-asset vault balances |
| `quote_swap(send, receive, amount, slippage_bps=50)` | `Quote` | dry preview |
| `quote_withdraw(asset, amount, to, mode="exact_out")` | `Quote` | dry preview |
| `swap(send, receive, amount, slippage_bps=50, min_receive=None, *, max_debit=None, idempotency_key=None)` | `Movement` | full key |
| `withdraw(asset, amount, to, mode="exact_out", *, max_debit=None, idempotency_key=None)` | `Movement` | full key |
| `movement(id)` | `Movement` | one movement, with fee breakdown |
| `movements(limit=50, cursor=None)` | `tuple[list[Movement], str \| None]` | page + next cursor (newest first) |
| `wait(id, timeout=120, poll=2.0)` | `Movement` | poll to a terminal status |
| `aclose()` | — | release the underlying HTTP client |

The default HTTP timeout is `httpx.Timeout(60s, connect=10s)` (the co-sign is multi-round);
override it with `Wallet(..., timeout=…)`.

## Data models

Every amount field is a **raw integer string**. All models are frozen dataclasses.

- **`Asset`** — `asset`, `symbol`, `chain`, `decimals: int`
- **`Balance`** — `asset`, `symbol`, `chain`, `decimals: int`, `amount_raw`, `usd: str | None`
- **`Amount`** — `amount`, `asset`
- **`Receive`** — `amount`, `min` *(guaranteed)*, `asset`
- **`RouteFee`** — `amount`, `estimate: bool` *(`True` = rate-derived cross-asset, `False` = exact same-asset spread)*
- **`Fees`** — `asset`, `platform`, `network`, `route: RouteFee | None`, `total`, `usd: dict | None`
- **`Quote`** — `movement_id: str | None` *(None on a dry preview)*, `mode`, `send: Amount`,
  `debit: Amount` *(full vault debit = send + fees)*, `receive: Receive`, `fees: Fees`,
  `expires_at`, `sources: int | None`
- **`Movement`** — `id`, `type`, `status`, `send: Amount`, `receive: Receive`, `fees: Fees | None`,
  `dest_chain_tx_hash: str | None`, `dest_chain_explorer_url: str | None`, `created_at`,
  `completed_at: str | None`

Timestamps stay raw ISO-8601 strings — the SDK does not parse them to `datetime`.

## Errors

Every call raises a typed subclass of `PaymosError`, which carries `message` and `status` (the
HTTP status, or `None` for a transport-level failure). Branch on the type instead of
string-matching a message.

| Exception | When |
|---|---|
| `AuthError` | `401` — API key missing, malformed, or unrecognized |
| `Forbidden` | `403` — the key is valid but lacks the scope this call needs |
| `InsufficientFunds` | `400` — the vault balance can't cover the amount (plus fees) |
| `QuoteExpired` | `400` — the referenced quote expired; fetch a fresh one |
| `SlippageExceeded` | `400` or local — delivered / guaranteed amount fell below your floor |
| `CrossAssetWithdrawNotAllowed` | `400` — a withdraw changed the asset (that's a swap) |
| `RouteUnavailable` | `400` — no route could be quoted right now; retry shortly |
| `RateLimited` | `429` — carries `.retry_after` (seconds, or `None`) |
| `Conflict` | `409` — idempotency key reused with a different request |
| `PaymosError` | base — `404`, any other 4xx/5xx, and transport errors (`status=None`) |

```python
import asyncio
from paymos import Wallet, InsufficientFunds, SlippageExceeded, RateLimited, PaymosError

try:
    mv = await w.swap(send="USDC@base", receive="ETH@arb", amount="100", min_receive="0.03")
except SlippageExceeded:
    ...                                   # guaranteed receive below your floor — nothing signed
except InsufficientFunds:
    ...                                   # not enough balance for amount + fees
except RateLimited as e:
    await asyncio.sleep(e.retry_after or 1)
except PaymosError as e:
    print(e.status, e.message)
```

Calling `swap` / `withdraw` with a **read-only** key raises `PaymosError` *before* any movement is
created — a read key can never sign.

## Security

- A leaked **API key alone cannot move funds** — no share, no signature (and the key can be revoked).
- A leaked **share alone cannot move funds** — no server co-sign.
- Both are required, on purpose. The SDK additionally verifies every message it co-signs, and that
  the server's quote matches exactly what you approved — so a compromised server can't redirect
  funds or inflate an amount past your caps.

Treat a full `vs_live_…` secret like a private key, and back up the share — losing it means losing
your ability to co-sign.

## Examples

The source repository's `examples/` folder has a clickable local web demo (FastAPI, prod-pointed)
that loads balances, shows the fee breakdown, and — behind a confirm box — runs a real swap, plus a
minimal `demo.py` CLI.

## License

**Proprietary.** All rights reserved. Use of this SDK is subject to your agreement with Paymos.

