Metadata-Version: 2.4
Name: tristero
Version: 0.4.2
Summary: Library for trading on Tristero
Author-email: pty1 <pty11@proton.me>
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: certifi>=2023.7.22
Requires-Dist: eth-account>=0.8.0
Requires-Dist: glom>=25.12.0
Requires-Dist: httpx>=0.23.0
Requires-Dist: pydantic>=2.0.0
Requires-Dist: tenacity>=8.0.0
Requires-Dist: web3>=6.0.0
Requires-Dist: websockets>=10.0
Dynamic: license-file

# Tristero
[![PyPI version](https://badge.fury.io/py/tristero.svg)](https://badge.fury.io/py/tristero)
[![Python Support](https://img.shields.io/pypi/pyversions/tristero.svg)](https://pypi.org/project/tristero/)

This repository is home to Tristero's trading library.


### How it works

Tristero supports two primary swap mechanisms:

#### Permit2 Swaps (EVM-to-EVM)
- **Quote & Approve** - Request a quote and approve tokens via Permit2 (gasless approval)
- **Sign & Submit** - Sign an EIP-712 order and submit for execution
- **Monitor** - Track swap progress via WebSocket updates

#### Feather Swaps (UTXO-based)
- **Quote & Deposit** - Request a quote to receive a deposit address
- **Manual Transfer** - Send funds to the provided deposit address
- **Monitor** - Track swap completion via WebSocket updates

This library provides both high-level convenience functions and lower-level components for precise control.

### Installation
```
pip install tristero
```

### Environment Configuration

Tristero supports three environments: **PRODUCTION** (default), **STAGING**, and **LOCAL**.

Set the environment globally at startup:

```py
from tristero import set_config

set_config("STAGING")  # all subsequent calls use the staging API
```

Or override per call:

```py
quote = await get_swap_quote(..., env="LOCAL")
```

Every user-facing function in the SDK accepts an optional `env` keyword argument.

### Quick Start

#### Spot Swap (quote, sign, submit)

```py
import asyncio
import json
import os

from eth_account import Account

from tristero import get_swap_quote, sign_and_submit, make_async_w3


async def main() -> None:
    private_key = os.getenv("TEST_ACCOUNT_PRIVKEY")
    if not private_key:
        raise RuntimeError("Set TEST_ACCOUNT_PRIVKEY")

    wallet = Account.from_key(private_key).address
    w3 = make_async_w3(os.getenv("ARB_RPC_URL", "https://arbitrum-one-rpc.publicnode.com"))

    # 1. Get a quote (USDC -> WETH on Arbitrum)
    quote = await get_swap_quote(
        wallet=wallet,
        src_chain=42161,
        src_token="0xaf88d065e77c8cC2239327C5EDb3A432268e5831",  # USDC
        dst_chain=42161,
        dst_token="0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",  # WETH
        amount=1_000_000,  # 1 USDC (6 decimals)
    )
    print(json.dumps(quote, indent=2))

    # 2. Sign and submit (w3 required for Permit2 approval on source chain)
    result = await sign_and_submit(quote, private_key, w3=w3, wait=True, timeout=300)
    print(result)


asyncio.run(main())
```

#### Margin Position (quote, sign, submit)

```py
import asyncio
import json
import os

from eth_account import Account

from tristero import get_swap_quote, sign_and_submit


async def main() -> None:
    private_key = os.getenv("TEST_ACCOUNT_PRIVKEY")
    if not private_key:
        raise RuntimeError("Set TEST_ACCOUNT_PRIVKEY")

    wallet = Account.from_key(private_key).address

    # 1. Get a margin quote (2x leveraged USDC/WETH on Arbitrum)
    quote = await get_swap_quote(
        wallet=wallet,
        src_chain=42161,
        src_token="0xaf88d065e77c8cC2239327C5EDb3A432268e5831",  # USDC (collateral)
        dst_chain=42161,
        dst_token="0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",  # WETH (base)
        amount=1_000_000,  # 1 USDC collateral (6 decimals)
        leverage=2,
    )
    print(json.dumps(quote, indent=2))

    # 2. Sign and submit (no w3 needed for margin)
    result = await sign_and_submit(quote, private_key, wait=True, timeout=120)
    print(result)


asyncio.run(main())
```

### More Examples

#### Spot Swap (direct execution)

```py
import os
import asyncio

from eth_account import Account

from tristero import ChainID, TokenSpec, execute_permit2_swap, make_async_w3


async def main() -> None:
    private_key = os.getenv("TEST_ACCOUNT_PRIVKEY")
    if not private_key:
        raise RuntimeError("Set TEST_ACCOUNT_PRIVKEY")

    account = Account.from_key(private_key)

    arbitrum_rpc = os.getenv("ARB_RPC_URL", "https://arbitrum-one-rpc.publicnode.com")
    w3 = make_async_w3(arbitrum_rpc)

    result = await execute_permit2_swap(
        w3=w3,
        account=account,
        src_t=TokenSpec(chain_id=ChainID(42161), token_address="0xaf88d065e77c8cC2239327C5EDb3A432268e5831"),  # USDC (Arbitrum)
        dst_t=TokenSpec(chain_id=ChainID(8453), token_address="0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2"),  # USDT (Base)
        raw_amount=1_000_000,  # 1 USDC (6 decimals)
        timeout=300,
    )
    print(result)


asyncio.run(main())
```

#### Margin: Direct Open

```py
import asyncio
import os

from eth_account import Account
from tristero import open_margin_position


async def main() -> None:
    private_key = os.getenv("TEST_ACCOUNT_PRIVKEY", "")
    if not private_key:
        raise RuntimeError("Set TEST_ACCOUNT_PRIVKEY")

    wallet = Account.from_key(private_key).address

    result = await open_margin_position(
        private_key=private_key,
        chain_id="42161",
        wallet_address=wallet,
        collateral_token="0xaf88d065e77c8cC2239327C5EDb3A432268e5831",  # USDC
        base_token="0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",  # WETH
        leverage_ratio=2,
        collateral_amount="1000000",  # 1 USDC (6 decimals)
        wait_for_result=True,
        timeout=120,
    )
    print(result)


asyncio.run(main())
```

Any token can be used as collateral. For example, open a 2x long WETH position
using USDT0:

```py
result = await open_margin_position(
    private_key=private_key,
    chain_id="42161",
    wallet_address=wallet,
    collateral_token="0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",  # USDT0
    base_token="0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",        # WETH
    leverage_ratio=2,
    collateral_amount="1000000",  # 1 USDT0 (6 decimals)
)
```

Or use WETH itself as collateral:

```py
result = await open_margin_position(
    private_key=private_key,
    chain_id="42161",
    wallet_address=wallet,
    collateral_token="0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",  # WETH
    base_token="0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",        # WETH
    leverage_ratio=2,
    collateral_amount="500000000000000",  # 0.0005 WETH (18 decimals)
)
```

The quote response includes `loan_token`, `collateral_token`, and `base_token`
objects with full metadata, plus `interest_rate_bps` for the borrowing cost.

#### Margin: List Positions / Close Position

```py
import asyncio
import os

from eth_account import Account
from tristero import close_margin_position, list_margin_positions


async def main() -> None:
    private_key = os.getenv("TEST_ACCOUNT_PRIVKEY", "")
    if not private_key:
        raise RuntimeError("Set TEST_ACCOUNT_PRIVKEY")

    wallet = Account.from_key(private_key).address

    positions = await list_margin_positions(wallet)
    open_pos = next((p for p in positions if p.status == "open"), None)
    if not open_pos:
        raise RuntimeError("no open positions")

    result = await close_margin_position(
        private_key=private_key,
        chain_id="42161",
        position_id=open_pos.taker_token_id,
        escrow_contract=open_pos.escrow_address,
        authorized=open_pos.filler_address,
        cash_settle=False,
        fraction_bps=10_000,
        deadline_seconds=3600,
        wait_for_result=True,
        timeout=120,
    )
    print(result)


asyncio.run(main())
```

#### WebSocket Quote Streaming

`subscribe_quotes` opens a persistent WebSocket connection and delivers live quotes via an async callback (~500 ms updates).

If the callback is still running when a newer quote arrives, intermediate updates are **dropped** and only the latest quote is delivered once the callback finishes (latest-only pattern). This makes it safe to do slow work (e.g. sign + submit) inside the callback without worrying about duplicate executions.

**Simple: print every quote**

```py
import asyncio
from tristero import subscribe_quotes


async def main() -> None:
    async def on_quote(quote):
        print(f"dst_qty={quote['dst_token_quantity']}  order_id={quote['order_id'][:16]}...")

    async def on_error(exc):
        print(f"Error: {exc}")

    async with await subscribe_quotes(
        wallet="0xYOUR_WALLET",
        src_chain=42161,
        src_token="0xaf88d065e77c8cC2239327C5EDb3A432268e5831",  # USDC
        dst_chain=1,
        dst_token="0xdAC17F958D2ee523a2206206994597C13D831ec7",  # USDT
        amount=1_000_000,
        on_quote=on_quote,
        on_error=on_error,
    ) as sub:
        await asyncio.sleep(10)  # stream for 10 seconds


asyncio.run(main())
```

**Advanced: sign and submit the first quote, then stop**

```py
import asyncio
import os

from eth_account import Account
from tristero import subscribe_quotes, sign_and_submit, make_async_w3


async def main() -> None:
    private_key = os.getenv("TEST_ACCOUNT_PRIVKEY", "")
    wallet = Account.from_key(private_key).address
    w3 = make_async_w3(os.getenv("ARB_RPC_URL", "https://arbitrum-one-rpc.publicnode.com"))

    done = asyncio.Event()

    async def on_quote(quote):
        if done.is_set():
            return
        quote["_type"] = "swap"
        result = await sign_and_submit(quote, private_key, w3=w3, wait=False)
        print(result)
        done.set()

    sub = await subscribe_quotes(
        wallet=wallet,
        src_chain=42161,
        src_token="0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
        dst_chain=42161,
        dst_token="0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
        amount=1_000_000,
        on_quote=on_quote,
    )
    await done.wait()
    await sub.close()


asyncio.run(main())
```

**Limit order: wait for a target price, then submit**

```py
import asyncio
import os

from eth_account import Account
from tristero import subscribe_quotes, sign_and_submit, make_async_w3


async def main() -> None:
    private_key = os.getenv("TEST_ACCOUNT_PRIVKEY", "")
    wallet = Account.from_key(private_key).address
    w3 = make_async_w3(os.getenv("ARB_RPC_URL", "https://arbitrum-one-rpc.publicnode.com"))

    done = asyncio.Event()
    baseline: list[int] = []
    improvement_bps = 10  # submit when price is 10 bps better than first quote

    async def on_quote(quote):
        if done.is_set():
            return
        dst_qty = int(quote.get("dst_token_quantity", 0))

        if not baseline:
            baseline.append(dst_qty)
            print(f"Baseline: {dst_qty}")
            return

        threshold = baseline[0] * (10_000 + improvement_bps) / 10_000
        if dst_qty < threshold:
            print(f"dst_qty={dst_qty} (waiting for >= {threshold:.0f})")
            return

        # Threshold met — sign and submit THIS specific quote
        print(f"Target reached: {dst_qty} >= {threshold:.0f}, submitting!")
        quote["_type"] = "swap"
        result = await sign_and_submit(quote, private_key, w3=w3, wait=False)
        print(result)
        done.set()

    sub = await subscribe_quotes(
        wallet=wallet,
        src_chain=42161,
        src_token="0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
        dst_chain=42161,
        dst_token="0x82aF49447D8a07e3bd95BD0d56f35241523fBab1",
        amount=1_000_000,
        on_quote=on_quote,
    )
    await done.wait()
    await sub.close()


asyncio.run(main())
```

The callback receives the exact quote that triggered the condition. Because of the
latest-only pattern, even if signing takes longer than 500 ms, that specific quote
is what gets signed — newer arrivals simply queue up and don't cause duplicates.

#### Feather: Start (get deposit address)

Feather swaps are deposit-based: you start an order to receive a `deposit_address`, send funds to it manually, then optionally wait for completion.

Submit only:

```py
import asyncio

from tristero import ChainID, TokenSpec, start_feather_swap


async def main() -> None:
    # Example: ETH (native) -> XMR (native)
    src_t = TokenSpec(chain_id=ChainID.ethereum, token_address="native")
    dst_t = TokenSpec(chain_id=ChainID.monero, token_address="native")

    # Replace with your own destination address on the destination chain.
    dst_addr = "YOUR_XMR_ADDRESS"

    swap = await start_feather_swap(
        src_t=src_t,
        dst_t=dst_t,
        dst_addr=dst_addr,
        raw_amount=100_000_000_000_000_000,  # 0.1 ETH in wei
    )

    order_id = (
        (swap.data or {}).get("id")
        or (swap.data or {}).get("order_id")
        or (swap.data or {}).get("orderId")
        or ""
    )

    print("order_id:", order_id)
    print("deposit_address:", swap.deposit_address)


asyncio.run(main())
```

Submit + wait (WebSocket):

```py
import asyncio

from tristero import ChainID, OrderType, TokenSpec, start_feather_swap, wait_for_completion


async def main() -> None:
    src_t = TokenSpec(chain_id=ChainID.ethereum, token_address="native")
    dst_t = TokenSpec(chain_id=ChainID.monero, token_address="native")
    dst_addr = "YOUR_XMR_ADDRESS"

    swap = await start_feather_swap(
        src_t=src_t,
        dst_t=dst_t,
        dst_addr=dst_addr,
        raw_amount=100_000_000_000_000_000,
    )

    order_id = (
        (swap.data or {}).get("id")
        or (swap.data or {}).get("order_id")
        or (swap.data or {}).get("orderId")
        or ""
    )
    if not order_id:
        raise RuntimeError(f"Feather swap response missing order id: {swap.data}")

    print("deposit_address:", swap.deposit_address)
    print("Waiting for completion...")
    completion = await wait_for_completion(order_id, order_type=OrderType.FEATHER)
    print(completion)


asyncio.run(main())
```
