Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ Shared, language-neutral material lives at the root:

## Wallets

Every protocol client signs through the `WalletProvider` seam, so the wallet is a construction-time choice. Three backends ship today:
Every protocol client signs through the `WalletProvider` seam, so the wallet is a construction-time choice. Four backends ship today:

| Wallet | Custody | SDK | Notes |
| --- | --- | --- | --- |
| `EVMWalletProvider` | local key (Keystore V3 on disk) | Python + TypeScript | full signing surface (`sign.message/transaction/typed_data`), MegaFuel paymaster support |
| `TWAKProvider` | [Trust Wallet Agent Kit](./docs/twak.md) CLI (`twak` >= v0.20.0) | Python + TypeScript | self-broadcasting; ERC-8004/8183 intents + delegated x402; sponsored testnet writes via `--paymaster-url` |
| `AltanaWalletProvider` | [Altana](./docs/altana.md) EIP-7702 wallet, on-chain session keys | TypeScript | self-broadcasting via relay; session-key x402 payer (Altana SDK >= 0.4.0); testnet preset, balances + ephemeral sessions (>= 0.5.0) |
| `TurnkeyWalletProvider` | [Turnkey](https://docs.turnkey.com) remote signing — keys live in AWS Nitro enclaves, the agent holds only a P-256 API key | Python + TypeScript | full signing surface + MegaFuel paymaster; every successful signature is billed (free tier 25/month at 1 req/s, PAYG $0.10); production requires a non-root API user + explicit ALLOW policy (root keys bypass all Turnkey policies) |

Details: [`python/bnbagent/wallets/README.md`](./python/bnbagent/wallets/README.md) (EVM + TWAK), [`typescript/README.md`](./typescript/README.md) (EVM + TWAK + Altana), [`docs/twak.md`](./docs/twak.md), and [`docs/altana.md`](./docs/altana.md).
Details: [`python/bnbagent/wallets/README.md`](./python/bnbagent/wallets/README.md) (EVM + TWAK + Turnkey), [`typescript/README.md`](./typescript/README.md) (EVM + TWAK + Altana + Turnkey), [`docs/twak.md`](./docs/twak.md), and [`docs/altana.md`](./docs/altana.md).

## Getting started

Expand Down
2 changes: 1 addition & 1 deletion python/bnbagent/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
Hatchling reads this file via [tool.hatch.version] in pyproject.toml,
so changing the literal below is the only thing needed to bump versions.
"""
__version__ = "0.4.2"
__version__ = "0.4.3"
20 changes: 20 additions & 0 deletions python/bnbagent/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ def __post_init__(self):
):
from ..wallets import create_wallet_provider

if self.wallet_kind.strip().lower() == "turnkey":
# Remote enclave signer, credentials from the TURNKEY_* env
# vars. Pin the network's chain id so a mismatching
# transaction fails closed before Turnkey's billable API
# call (mirrors the TypeScript ERC8183Config branch).
from eth_utils import to_checksum_address

from ..wallets.errors import WalletIdentityMismatch
from ..wallets.turnkey import TurnkeyWalletProvider

provider = TurnkeyWalletProvider.from_env(
expected_chain_id=self.effective_network.chain_id
)
if self.wallet_address:
expected = to_checksum_address(self.wallet_address)
if expected != provider.address:
raise WalletIdentityMismatch(expected=expected, actual=provider.address)
self.wallet_provider = provider
return

kwargs: dict[str, str] = {}
if self.wallet_kind.strip().lower() == "twak":
# Pin twak to the config's network (chain identity must not
Expand Down
23 changes: 23 additions & 0 deletions python/bnbagent/wallets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ There is **no shared key store** across providers - each owns its own custody, a
| `EVMWalletProvider` | `evm` | `~/.bnbagent/wallets/<address>.json` (Keystore V3) |
| `TWAKProvider` | `twak` | `<home or ~>/.twak/wallet.json` (encrypted mnemonic) + OS keychain / `TWAK_WALLET_PASSWORD` |
| `MPCWalletProvider` | `mpc` | external MPC enclave (subclass-defined) |
| `TurnkeyWalletProvider` | `turnkey` | [Turnkey](https://docs.turnkey.com)'s AWS Nitro enclave — the key never leaves it; the agent holds only a local P-256 API key |

The twak CLI exposes no private-key `import`/`export` or `--keystore-path`, so its key cannot be shared with the SDK keystore (or vice versa). Treat "choose a provider" as "choose a custodian"; use `describe()` / `key_location` to report where a wallet's key lives without unifying storage.

Expand All @@ -102,6 +103,7 @@ from bnbagent.wallets import create_wallet_provider

wallet = create_wallet_provider("evm", password="pw") # -> EVMWalletProvider
wallet = create_wallet_provider("twak", chain="bsc") # -> TWAKProvider
wallet = TurnkeyWalletProvider.from_env() # -> TurnkeyWalletProvider (TURNKEY_* env)
print(wallet.describe()) # {"kind": "twak", "address": "0x..", "key_location": "...",
# "exists": True, "capabilities": ["broadcast.self", ...]}
```
Expand Down Expand Up @@ -294,6 +296,27 @@ Default `IntentExecutor`. Builds, signs (via the wrapped `WalletProvider`) and b
- `IntentExecutor.execute(intent) -> dict` - returns at least `{"transactionHash", "receipt"}` (executors may add `agentId`, etc.).
- `ExecutionContext(web3, paymaster, receipt_timeout)` - the context a pure signer needs to build a `LocalExecutor`.

### `TurnkeyWalletProvider`

Pure signer over [Turnkey](https://docs.turnkey.com)'s remote key-management API: the private key is generated and held inside Turnkey's AWS Nitro enclave and never leaves it; every sign call is an HTTPS activity stamped by a locally held P-256 API key pair (`pip install 'bnbagent[turnkey]'` for the `cryptography` extra, imported lazily on first use). Same capability surface as `EVMWalletProvider` (`sign.*` x3 + `calls.arbitrary` + `paymaster.sponsor`), so ERC-8004/8183 writes ride the default `LocalExecutor` and x402 rides `X402Signer` unchanged.

```python
from bnbagent.wallets import TurnkeyWalletProvider

# env: TURNKEY_API_PUBLIC_KEY / TURNKEY_API_PRIVATE_KEY / TURNKEY_ORG_ID /
# TURNKEY_SIGN_WITH (the wallet account's 0x ADDRESS, not a wallet id)
wallet = TurnkeyWalletProvider.from_env(expected_chain_id=97)
```

Operational constraints (the same list as the TypeScript provider):

- **Every successful signature is billed** — free tier 25/month at 1 request/second; pay-as-you-go $0.10/signature. All SDK-side guards (`SigningPolicy`, the `expected_chain_id` pin, input validation) run *before* the API call, so a refusal never costs quota.
- **Root API keys bypass ALL Turnkey server-side policies** (root quorum). Production deployments need a non-root API user plus an explicit ALLOW policy; this cannot be detected client-side.
- **Broadcast is never delegated** (Turnkey's managed broadcast is a paid feature) — the default `LocalExecutor` broadcasts over the SDK's own RPC, and the MegaFuel paymaster path works unchanged.
- EIP-712 payloads are built by this module with the `EIP712Domain` entry always included, and EIP-191 messages are hashed locally and blind-signed (Turnkey's policies cannot see message content — content-level control is the SDK-side policy's job). Supported transactions: legacy + EIP-1559 (no access lists).

Live verification: `TURNKEY_E2E=1 uv run python examples/turnkey_e2e.py` (4 billed signatures; see the script header).

### `MPCWalletProvider` (stub)

Stub-by-design slot for external MPC custody such as Coinbase CDP or Fireblocks: subclass it and implement `address` plus the `sign_*` methods your backend supports - `capabilities()` derives the matching `sign.*` entries from those overrides automatically. The raise-only `sign_*` stubs were removed (an override that only raises would falsely claim the capability); unimplemented methods keep the raising base default. Direct instantiation still raises `NotImplementedError`.
Expand Down
2 changes: 2 additions & 0 deletions python/bnbagent/wallets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .intents import ExecutionContext, Intent, IntentExecutor
from .mpc_wallet_provider import MPCWalletProvider
from .protocols import MessageSigner, TypedDataSigner
from .turnkey import TurnkeyWalletProvider
from .twak_custody import materialize_twak_home
from .twak_provider import TWAK_CHAIN_FOR_NETWORK, TWAKProvider
from .wallet_provider import WalletProvider
Expand All @@ -23,6 +24,7 @@
"EVMWalletProvider",
"MPCWalletProvider",
"TWAKProvider",
"TurnkeyWalletProvider",
"UnsupportedWalletOperation",
"WalletIdentityMismatch",
"materialize_twak_home",
Expand Down
15 changes: 11 additions & 4 deletions python/bnbagent/wallets/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,21 @@
from .wallet_provider import WalletProvider

#: Wallet kinds the factory can construct. Mirrors each provider's ``kind``.
SUPPORTED_WALLET_KINDS: tuple[str, ...] = ("evm", "twak", "mpc")
SUPPORTED_WALLET_KINDS: tuple[str, ...] = ("evm", "twak", "mpc", "turnkey")


def create_wallet_provider(kind: str, **kwargs: Any) -> WalletProvider:
"""Construct a :class:`WalletProvider` for ``kind``.

Args:
kind: Provider identifier (case-insensitive): ``"evm"``, ``"twak"``
or ``"mpc"``.
kind: Provider identifier (case-insensitive): ``"evm"``, ``"twak"``,
``"mpc"`` or ``"turnkey"``.
**kwargs: Forwarded verbatim to the provider constructor. Required
arguments differ per kind — e.g. ``EVMWalletProvider`` needs
``password=...``; ``TWAKProvider`` accepts ``chain=...``.
``password=...``; ``TWAKProvider`` accepts ``chain=...``;
``TurnkeyWalletProvider`` needs the ``organization_id`` /
``sign_with`` / ``api_public_key`` / ``api_private_key``
credentials (or use ``TurnkeyWalletProvider.from_env()``).

Returns:
The constructed provider.
Expand All @@ -55,6 +58,10 @@ def create_wallet_provider(kind: str, **kwargs: Any) -> WalletProvider:
from .mpc_wallet_provider import MPCWalletProvider

return MPCWalletProvider(**kwargs)
if normalized == "turnkey":
from .turnkey import TurnkeyWalletProvider

return TurnkeyWalletProvider(**kwargs)

raise ValueError(
f"Unknown wallet kind {kind!r}. "
Expand Down
17 changes: 17 additions & 0 deletions python/bnbagent/wallets/turnkey/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Turnkey wallet provider — remote signing, keys in AWS Nitro Enclaves.

The internal stamper/client plumbing stays out of the package barrel; the
public surface is the provider plus the API-host constant.
"""

from __future__ import annotations

from .client import TURNKEY_API_BASE_URL_DEFAULT, TurnkeyApiError, TurnkeyClient
from .provider import TurnkeyWalletProvider

__all__ = [
"TURNKEY_API_BASE_URL_DEFAULT",
"TurnkeyApiError",
"TurnkeyClient",
"TurnkeyWalletProvider",
]
210 changes: 210 additions & 0 deletions python/bnbagent/wallets/turnkey/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""Minimal Turnkey activity client — the two signing endpoints only.

Turnkey has no official Python SDK (only a stamper utility), so this module
implements the thin slice the wallet provider needs, mirroring the wire
behavior of ``@turnkey/http``:

- ``POST /public/v1/submit/sign_raw_payload``
(``ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2``)
- ``POST /public/v1/submit/sign_transaction``
(``ACTIVITY_TYPE_SIGN_TRANSACTION_V2``)
- ``POST /public/v1/query/get_activity`` (short poll for the rare
still-pending activity; sign activities normally execute synchronously)

Every request body is stamped (see :mod:`.stamper`) over the exact bytes
sent. Free-tier reality baked into callers: every *successful* signature is
billed (25/month at 1 request/second), so callers gate everything they can
BEFORE invoking this client.
"""

from __future__ import annotations

import json
import time
from typing import Any

import requests

from .stamper import ApiKeyStamper

TURNKEY_API_BASE_URL_DEFAULT = "https://api.turnkey.com"

_ACTIVITY_TERMINAL_OK = "ACTIVITY_STATUS_COMPLETED"
_ACTIVITY_IN_FLIGHT = ("ACTIVITY_STATUS_CREATED", "ACTIVITY_STATUS_PENDING")
_POLL_ATTEMPTS = 10
_POLL_INTERVAL_S = 0.5


class TurnkeyApiError(RuntimeError):
"""A Turnkey API request failed (transport, HTTP or activity level)."""

def __init__(
self,
message: str,
*,
status_code: int | None = None,
activity_status: str | None = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.activity_status = activity_status


class TurnkeyClient:
"""Stamped HTTP client for the two Turnkey signing activities."""

def __init__(
self,
*,
api_base_url: str,
api_public_key: str,
api_private_key: str,
organization_id: str,
session: requests.Session | None = None,
timeout: float = 30.0,
) -> None:
self._base_url = api_base_url.rstrip("/")
self._organization_id = organization_id
self._stamper = ApiKeyStamper(
api_public_key=api_public_key, api_private_key=api_private_key
)
self._session = session or requests.Session()
self._timeout = timeout

# ── Activities ────────────────────────────────────────────────────

def sign_raw_payload(
self,
*,
sign_with: str,
payload: str,
encoding: str,
hash_function: str,
) -> dict[str, str]:
"""Run ``SIGN_RAW_PAYLOAD_V2``; returns ``{"r", "s", "v"}``.

``r``/``s`` are 32-byte hex strings and ``v`` is the recovery id as
hex (``"00"``/``"01"``) — all without a ``0x`` prefix, exactly as
the API returns them. Callers normalize.
"""
activity = self._submit(
"/public/v1/submit/sign_raw_payload",
{
"type": "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2",
"organizationId": self._organization_id,
"parameters": {
"signWith": sign_with,
"payload": payload,
"encoding": encoding,
"hashFunction": hash_function,
},
"timestampMs": _timestamp_ms(),
},
)
result = (activity.get("result") or {}).get("signRawPayloadResult")
if not result:
raise TurnkeyApiError(
"Turnkey activity completed without a signRawPayloadResult",
activity_status=activity.get("status"),
)
return {"r": result["r"], "s": result["s"], "v": result["v"]}

def sign_transaction(self, *, sign_with: str, unsigned_transaction: str) -> str:
"""Run ``SIGN_TRANSACTION_V2``; returns the signed RLP hex (no ``0x``).

``unsigned_transaction`` is the serialized unsigned transaction hex
without a ``0x`` prefix (legacy or EIP-1559 — the enclave parses
either).
"""
activity = self._submit(
"/public/v1/submit/sign_transaction",
{
"type": "ACTIVITY_TYPE_SIGN_TRANSACTION_V2",
"organizationId": self._organization_id,
"parameters": {
"signWith": sign_with,
"type": "TRANSACTION_TYPE_ETHEREUM",
"unsignedTransaction": unsigned_transaction,
},
"timestampMs": _timestamp_ms(),
},
)
result = (activity.get("result") or {}).get("signTransactionResult") or {}
signed = result.get("signedTransaction")
if not signed:
raise TurnkeyApiError(
"Turnkey activity completed without a signedTransaction",
activity_status=activity.get("status"),
)
return str(signed)

# ── Plumbing ──────────────────────────────────────────────────────

def _submit(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
activity = self._post(path, body)
for _ in range(_POLL_ATTEMPTS):
status = activity.get("status")
if status == _ACTIVITY_TERMINAL_OK:
return activity
if status not in _ACTIVITY_IN_FLIGHT:
raise TurnkeyApiError(
f"Turnkey activity ended in {status}: "
f"{activity.get('failure') or activity.get('type', '')}".strip(),
activity_status=status,
)
time.sleep(_POLL_INTERVAL_S)
activity = self._post(
"/public/v1/query/get_activity",
{
"organizationId": self._organization_id,
"activityId": activity.get("id", ""),
},
)
raise TurnkeyApiError(
"Turnkey activity still pending after "
f"{_POLL_ATTEMPTS * _POLL_INTERVAL_S:.0f}s of polling",
activity_status=activity.get("status"),
)

def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
# The stamp signs the exact bytes on the wire, so serialize once and
# send that same string.
payload = json.dumps(body, separators=(",", ":"))
header_name, header_value = self._stamper.stamp(payload)
response = self._session.post(
f"{self._base_url}{path}",
data=payload.encode("utf-8"),
headers={
"Content-Type": "application/json",
header_name: header_value,
},
timeout=self._timeout,
)
if response.status_code >= 400:
raise TurnkeyApiError(
f"Turnkey API {path} failed with HTTP {response.status_code}: "
f"{_error_message(response)}",
status_code=response.status_code,
)
try:
parsed = response.json()
except ValueError as exc:
raise TurnkeyApiError(f"Turnkey API {path} returned non-JSON body") from exc
activity = parsed.get("activity")
if not isinstance(activity, dict):
raise TurnkeyApiError(f"Turnkey API {path} response has no activity envelope")
return activity


def _error_message(response: requests.Response) -> str:
try:
body = response.json()
except ValueError:
return response.text[:500]
if isinstance(body, dict):
return str(body.get("message") or body)[:500]
return str(body)[:500]


def _timestamp_ms() -> str:
return str(int(time.time() * 1000))
Loading
Loading