diff --git a/README.md b/README.md
index c77d6fc..7d5d175 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/python/bnbagent/__init__.py b/python/bnbagent/__init__.py
index f9d503a..f93154e 100644
--- a/python/bnbagent/__init__.py
+++ b/python/bnbagent/__init__.py
@@ -26,15 +26,28 @@
from __future__ import annotations
-# ERC-8183 — only essential public API
-from .erc8183 import ERC8183Client, JobStatus, Verdict
+from ._version import __version__
# Configuration
from .config import NetworkConfig
+# Transaction tuning (gas-price floor + receipt timeout) — public knobs that
+# replace any downstream monkey-patching of SDK internals.
+from .core.contract_mixin import (
+ get_default_receipt_timeout,
+ set_default_receipt_timeout,
+ set_min_gas_price_wei,
+)
+
+# Opt-in .env loading (never called at import time — applications opt in)
+from .core.env import load_env
+
# ERC-8004 Identity Registry
from .erc8004 import AgentEndpoint, ERC8004Agent
+# ERC-8183 — only essential public API
+from .erc8183 import ERC8183Client, JobStatus, Verdict
+
# Exceptions
from .exceptions import (
BNBAgentError,
@@ -42,29 +55,18 @@
TransactionPendingError,
)
-# Opt-in .env loading (never called at import time — applications opt in)
-from .core.env import load_env
-
-# Transaction tuning (gas-price floor + receipt timeout) — public knobs that
-# replace any downstream monkey-patching of SDK internals.
-from .core.contract_mixin import (
- get_default_receipt_timeout,
- set_default_receipt_timeout,
- set_min_gas_price_wei,
-)
+# Signing policy
+from .signing import PolicyViolation, SigningPolicy
# Wallets
from .wallets import EVMWalletProvider, WalletProvider
-# Signing policy
-from .signing import PolicyViolation, SigningPolicy
-
# x402 payment signer
from .x402 import X402Signer
-from ._version import __version__
__all__ = [
# Core
+ "__version__",
"NetworkConfig",
"BNBAgentError",
"TransactionPendingError",
diff --git a/python/bnbagent/_version.py b/python/bnbagent/_version.py
index ae4e3f1..ea37fa4 100644
--- a/python/bnbagent/_version.py
+++ b/python/bnbagent/_version.py
@@ -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"
diff --git a/python/bnbagent/core/config.py b/python/bnbagent/core/config.py
index 3f5ea4f..5213120 100644
--- a/python/bnbagent/core/config.py
+++ b/python/bnbagent/core/config.py
@@ -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
diff --git a/python/bnbagent/core/contract_mixin.py b/python/bnbagent/core/contract_mixin.py
index 5265ccc..9045839 100644
--- a/python/bnbagent/core/contract_mixin.py
+++ b/python/bnbagent/core/contract_mixin.py
@@ -145,9 +145,7 @@ def _execute_intent(self, intent) -> dict[str, Any]:
# how to use it (the local executor sponsors when sponsorable and
# self-pays otherwise; a self-broadcasting wallet ignores it).
executor = self._wallet_provider.make_executor(
- ExecutionContext(
- web3=self.w3, paymaster=getattr(self, "_paymaster", None)
- )
+ ExecutionContext(web3=self.w3, paymaster=getattr(self, "_paymaster", None))
)
self._intent_executor = executor
return executor.execute(intent)
@@ -203,6 +201,7 @@ def _send_tx(
# Skipped when skip_preflight=True (e.g. when node returns opaque 0x reverts).
if not skip_preflight:
import concurrent.futures as _cf
+
_call_params = {
"from": self._account,
"to": tx.get("to"),
@@ -215,23 +214,28 @@ def _send_tx(
try:
_future.result(timeout=10)
except _cf.TimeoutError:
- logger.warning(f"[{class_name}] Pre-flight eth_call timed out, proceeding anyway")
+ logger.warning(
+ f"[{class_name}] Pre-flight eth_call timed out, proceeding anyway"
+ )
except Exception as preflight_err:
err_str = str(preflight_err)
# Skip pre-flight if node returns opaque 0x (no revert data)
if "'0x'" in err_str or err_str.strip().endswith(", '0x')"):
- logger.warning(f"[{class_name}] Pre-flight returned opaque 0x revert, proceeding to on-chain tx")
+ logger.warning(
+ f"[{class_name}] Pre-flight returned opaque 0x revert, "
+ "proceeding to on-chain tx"
+ )
else:
- raise RuntimeError(f"Transaction would revert: {preflight_err}") from preflight_err
+ raise RuntimeError(
+ f"Transaction would revert: {preflight_err}"
+ ) from preflight_err
signed = self._wallet_provider.sign_transaction(tx)
raw_tx = signed["rawTransaction"]
tx_hash = self.w3.eth.send_raw_transaction(raw_tx)
timeout = get_default_receipt_timeout()
try:
- receipt = self.w3.eth.wait_for_transaction_receipt(
- tx_hash, timeout=timeout
- )
+ receipt = self.w3.eth.wait_for_transaction_receipt(tx_hash, timeout=timeout)
except TimeExhausted as exc:
# Broadcast OK (nonce consumed) but unconfirmed in time —
# surface as pending with the hash, never as a fatal/retry
diff --git a/python/bnbagent/core/multicall.py b/python/bnbagent/core/multicall.py
index 8b97eb0..3c0305d 100644
--- a/python/bnbagent/core/multicall.py
+++ b/python/bnbagent/core/multicall.py
@@ -58,6 +58,7 @@ def _get_output_types(contract: Contract, function_name: str) -> list[str]:
return [_abi_type(o) for o in item.get("outputs", [])]
raise ValueError(f"Function {function_name} not found in ABI")
+
# Canonical Multicall3 address — same on all EVM chains
MULTICALL3_ADDRESS = "0xcA11bde05977b3631167028862bE2a173976CA11"
@@ -152,7 +153,7 @@ def multicall_read(
batch = encoded_calls[i : i + batch_size]
raw_results = _aggregate3_with_retry(multicall3, batch)
- for j, (success, return_data) in enumerate(raw_results):
+ for success, return_data in raw_results:
if success and return_data:
try:
decoded = abi_decode(output_types, return_data)
@@ -181,8 +182,7 @@ def _aggregate3_with_retry(multicall3: Contract, calls: list[dict]) -> list[tupl
if is_rate_limit and attempt < MAX_RETRIES - 1:
delay = RETRY_BASE_DELAY * (2**attempt)
logger.warning(
- f"[Multicall3] Rate limited, retry {attempt + 1}/{MAX_RETRIES} "
- f"in {delay:.1f}s"
+ f"[Multicall3] Rate limited, retry {attempt + 1}/{MAX_RETRIES} in {delay:.1f}s"
)
time.sleep(delay)
else:
diff --git a/python/bnbagent/erc8004/agent.py b/python/bnbagent/erc8004/agent.py
index a168cec..4f23536 100644
--- a/python/bnbagent/erc8004/agent.py
+++ b/python/bnbagent/erc8004/agent.py
@@ -22,8 +22,8 @@
from ..constants import SCAN_API_URL
from ..core.paymaster import Paymaster
from ..exceptions import ERC8004PartialRegistrationError, TransactionPendingError
-from .agent_uri import AgentURIGenerator
from ..wallets import WalletProvider
+from .agent_uri import AgentURIGenerator
from .constants import get_erc8004_config
from .contract import ContractInterface
from .models import AgentEndpoint
diff --git a/python/bnbagent/erc8004/constants.py b/python/bnbagent/erc8004/constants.py
index 705b23a..199afe5 100644
--- a/python/bnbagent/erc8004/constants.py
+++ b/python/bnbagent/erc8004/constants.py
@@ -8,6 +8,7 @@
from typing import Any
+from .._version import __version__ as _sdk_version
from ..config import resolve_network
from ..core.config import get_env
@@ -33,8 +34,6 @@ def get_erc8004_config(network: str = "bsc-testnet") -> dict[str, Any]:
}
-from .._version import __version__ as _sdk_version
-
BUILT_WITH_KEY = "built_with"
_BUILT_WITH_URL = "https://github.com/bnb-chain/bnbagent-sdk"
diff --git a/python/bnbagent/erc8004/models.py b/python/bnbagent/erc8004/models.py
index e42520c..3d1bb8e 100644
--- a/python/bnbagent/erc8004/models.py
+++ b/python/bnbagent/erc8004/models.py
@@ -108,8 +108,9 @@ def a2a(
invalid URL — the path is inserted before the query/fragment instead.
Example:
- >>> AgentEndpoint.a2a("https://agent.example")
- AgentEndpoint(name='A2A', endpoint='https://agent.example/.well-known/agent-card.json', ...)
+ >>> endpoint = AgentEndpoint.a2a("https://agent.example")
+ >>> endpoint.endpoint
+ 'https://agent.example/.well-known/agent-card.json'
"""
return cls(
name="A2A",
@@ -137,9 +138,7 @@ def _append_agent_card_path(cls, base_url: str) -> str:
path = parts.path.rstrip("/")
if not path.endswith(cls.A2A_WELL_KNOWN_PATH):
path += cls.A2A_WELL_KNOWN_PATH
- return urlunsplit(
- (parts.scheme, parts.netloc, path, parts.query, parts.fragment)
- )
+ return urlunsplit((parts.scheme, parts.netloc, path, parts.query, parts.fragment))
@classmethod
def mcp(
diff --git a/python/bnbagent/erc8183/__init__.py b/python/bnbagent/erc8183/__init__.py
index 7990bcc..a5c732c 100644
--- a/python/bnbagent/erc8183/__init__.py
+++ b/python/bnbagent/erc8183/__init__.py
@@ -16,6 +16,7 @@
from .client import DEFAULT_APPROVE_FLOOR_UNITS, ERC8183Client
from .commerce import CommerceClient
from .constants import get_erc8183_config
+from .job_ops import ERC8183JobOps, funded_job_watcher
from .negotiation import (
NegotiationHandler,
NegotiationRequest,
@@ -26,7 +27,6 @@
)
from .policy import PolicyClient
from .router import RouterClient
-from .job_ops import ERC8183JobOps, funded_job_watcher
from .schema import SCHEMA_VERSION, DeliverableManifest, JobDescription
from .types import (
REASON_APPROVED,
diff --git a/python/bnbagent/erc8183/client.py b/python/bnbagent/erc8183/client.py
index 2bdc77d..e598280 100644
--- a/python/bnbagent/erc8183/client.py
+++ b/python/bnbagent/erc8183/client.py
@@ -34,8 +34,8 @@
from ..config import NetworkConfig, resolve_network
from ..core.abi_loader import create_web3
-from ..wallets.wallet_provider import WalletProvider
from ..erc20.client import MinimalERC20Client
+from ..wallets.wallet_provider import WalletProvider
from .commerce import CommerceClient
from .policy import PolicyClient
from .router import RouterClient
@@ -110,9 +110,7 @@ def __init__(
)
self._wallet_provider = wallet_provider
- self.address: str | None = (
- wallet_provider.address if wallet_provider is not None else None
- )
+ self.address: str | None = wallet_provider.address if wallet_provider is not None else None
# Gas sponsorship: wire a paymaster into the write path only on
# networks where MegaFuel sponsors ERC-8183 (testnet today; mainnet
@@ -149,11 +147,7 @@ def _build_paymaster(nc: NetworkConfig, debug: bool):
Note: the ERC-20 ``approve`` inside :meth:`fund` runs through the
ERC-20 client's own self-pay path and is not sponsored here.
"""
- if (
- nc.use_paymaster
- and nc.paymaster_url
- and nc.chain_id in ERC8183_PAYMASTER_CHAIN_IDS
- ):
+ if nc.use_paymaster and nc.paymaster_url and nc.chain_id in ERC8183_PAYMASTER_CHAIN_IDS:
from ..core.paymaster import Paymaster
return Paymaster(paymaster_url=nc.paymaster_url, debug=debug)
@@ -170,9 +164,7 @@ def payment_token(self) -> str:
def _erc20_client(self) -> MinimalERC20Client:
if self._erc20 is None:
- self._erc20 = MinimalERC20Client(
- self.w3, self.payment_token, self._wallet_provider
- )
+ self._erc20 = MinimalERC20Client(self.w3, self.payment_token, self._wallet_provider)
return self._erc20
def token_decimals(self) -> int:
@@ -224,13 +216,14 @@ def create_job(
if not skip_expiry_check:
try:
import time
+
dispute_window = int(self.policy.dispute_window())
now = int(time.time())
if expired_at - now <= dispute_window:
raise ValueError(
f"expired_at ({expired_at}) is too close to now ({now}). "
f"OptimisticPolicy on this network has dispute_window="
- f"{dispute_window}s ({dispute_window/86400:.1f}d), so the "
+ f"{dispute_window}s ({dispute_window / 86400:.1f}d), so the "
f"submit deadline (expired_at - dispute_window = "
f"{expired_at - dispute_window}) is already in the past or "
f"within seconds. provider.submit() would revert with "
@@ -312,7 +305,9 @@ def fund(
cap = max(amount, floor)
logger.debug(
"[ERC8183Client] topping up allowance: current=%s amount=%s cap=%s",
- current, amount, cap,
+ current,
+ amount,
+ cap,
)
self.approve_payment_token(self.commerce.address, cap)
@@ -394,7 +389,9 @@ def get_deliverable_url(self, job_id: int, *, hint_block: int | None = None) ->
hint_block = self._resolve_submit_block(job_id)
return self.policy.get_deliverable_url(job_id, hint_block=hint_block)
- def _resolve_submit_block(self, job_id: int, *, lookback: int = 50_000, step: int = 1_000) -> int | None:
+ def _resolve_submit_block(
+ self, job_id: int, *, lookback: int = 50_000, step: int = 1_000
+ ) -> int | None:
"""Find the block where ``JobSubmitted`` was emitted for *job_id*.
Walks backwards from the current head in ``step``-block windows so
diff --git a/python/bnbagent/erc8183/config.py b/python/bnbagent/erc8183/config.py
index 782da76..daf4a33 100644
--- a/python/bnbagent/erc8183/config.py
+++ b/python/bnbagent/erc8183/config.py
@@ -132,7 +132,7 @@ def effective_policy_address(self) -> str:
@classmethod
def from_env(
cls,
- storage: "StorageProvider | None" = None,
+ storage: StorageProvider | None = None,
) -> ERC8183Config:
"""Load ERC-8183 configuration from the environment.
@@ -180,6 +180,7 @@ def from_env(
if storage is None:
from ..storage import LocalStorageProvider
+
storage = LocalStorageProvider.from_env()
return cls(
diff --git a/python/bnbagent/erc8183/job_ops.py b/python/bnbagent/erc8183/job_ops.py
index 7944db3..610da34 100644
--- a/python/bnbagent/erc8183/job_ops.py
+++ b/python/bnbagent/erc8183/job_ops.py
@@ -38,7 +38,7 @@
_DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024 # 5 MB
-_DEFAULT_MAX_METADATA_BYTES = 256 * 1024 # 256 KB
+_DEFAULT_MAX_METADATA_BYTES = 256 * 1024 # 256 KB
def _read_int_env(key: str, default: int) -> int:
@@ -53,7 +53,10 @@ def _read_int_env(key: str, default: int) -> int:
except ValueError:
logger.warning(
"[ERC8183JobOps] %s%s=%r invalid, using default %d",
- ERC8183_ENV_PREFIX, key, raw, default,
+ ERC8183_ENV_PREFIX,
+ key,
+ raw,
+ default,
)
return default
@@ -67,8 +70,14 @@ def _max_metadata_bytes() -> int:
_TRANSIENT_ERROR_KEYWORDS = (
- "timeout", "connection", "network", "rpc",
- "429", "too many requests", "rate limit", "limit exceeded",
+ "timeout",
+ "connection",
+ "network",
+ "rpc",
+ "429",
+ "too many requests",
+ "rate limit",
+ "limit exceeded",
)
# ── Semantic error codes (transport-neutral) ──
@@ -80,17 +89,17 @@ def _max_metadata_bytes() -> int:
# Retry contract: error dicts carry ``"retryable": True`` only for transient
# failures (``chain_unavailable``, ``internal_error``); absence means the
# failure is permanent and retrying cannot succeed.
-ERR_BUDGET_TOO_LOW = "budget_too_low" # budget < service_price
-ERR_NOT_ASSIGNED = "not_assigned" # job.provider != this agent
-ERR_NOT_FOUND = "not_found" # job / stored response missing
-ERR_JOB_EXPIRED = "job_expired" # past job.expiredAt
-ERR_WRONG_STATUS = "wrong_status" # job not in the required status
+ERR_BUDGET_TOO_LOW = "budget_too_low" # budget < service_price
+ERR_NOT_ASSIGNED = "not_assigned" # job.provider != this agent
+ERR_NOT_FOUND = "not_found" # job / stored response missing
+ERR_JOB_EXPIRED = "job_expired" # past job.expiredAt
+ERR_WRONG_STATUS = "wrong_status" # job not in the required status
ERR_DESCRIPTION_INVALID = "description_invalid" # malformed on-chain description (fail closed)
ERR_SUBMIT_DEADLINE_PASSED = "submit_deadline_passed" # past expiredAt - disputeWindow
ERR_PAYLOAD_TOO_LARGE = "payload_too_large" # response/metadata size cap hit
-ERR_INTERNAL = "internal_error" # unexpected failure (retryable)
+ERR_INTERNAL = "internal_error" # unexpected failure (retryable)
ERR_CHAIN_UNAVAILABLE = "chain_unavailable" # transient chain/RPC trouble (retryable)
-ERR_TX_PENDING = "tx_pending" # tx broadcast but unconfirmed (NOT retryable)
+ERR_TX_PENDING = "tx_pending" # tx broadcast but unconfirmed (NOT retryable)
def _exc_error_fields(exc: Exception) -> dict[str, Any]:
@@ -262,23 +271,19 @@ async def submit_result(
return {
"success": False,
"error": (
- f"response_content size {actual_resp} bytes exceeds "
- f"limit {max_resp} bytes"
+ f"response_content size {actual_resp} bytes exceeds limit {max_resp} bytes"
),
"error_code": ERR_PAYLOAD_TOO_LARGE,
}
if metadata is not None:
max_meta = _max_metadata_bytes()
- actual_meta = len(
- json.dumps(metadata, separators=(",", ":")).encode("utf-8")
- )
+ actual_meta = len(json.dumps(metadata, separators=(",", ":")).encode("utf-8"))
if actual_meta > max_meta:
return {
"success": False,
"error": (
- f"metadata size {actual_meta} bytes exceeds "
- f"limit {max_meta} bytes"
+ f"metadata size {actual_meta} bytes exceeds limit {max_meta} bytes"
),
"error_code": ERR_PAYLOAD_TOO_LARGE,
}
@@ -349,10 +354,14 @@ async def get_job(self, job_id: int) -> dict[str, Any]:
# Return a generic message — the raw exception can embed the RPC
# URL (and its API key) on transport errors. Classify here so
# callers still get the right status without parsing the message.
- is_net = any(k in str(exc).lower() for k in ("timeout", "connection", "network", "rpc"))
+ is_net = any(
+ k in str(exc).lower() for k in ("timeout", "connection", "network", "rpc")
+ )
return {
"success": False,
- "error": "Temporary chain/RPC error" if is_net else "Failed to fetch job from chain",
+ "error": "Temporary chain/RPC error"
+ if is_net
+ else "Failed to fetch job from chain",
"error_code": ERR_CHAIN_UNAVAILABLE if is_net else ERR_INTERNAL,
"retryable": True,
}
@@ -387,9 +396,7 @@ async def get_response(self, job_id: int) -> dict[str, Any]:
try:
erc8183 = self._get_client()
- deliverable_url = await asyncio.to_thread(
- erc8183.get_deliverable_url, job_id
- )
+ deliverable_url = await asyncio.to_thread(erc8183.get_deliverable_url, job_id)
if deliverable_url:
self._deliverable_urls[job_id] = deliverable_url
data = await self._storage.download(deliverable_url)
@@ -406,7 +413,9 @@ async def get_response(self, job_id: int) -> dict[str, Any]:
"retryable": True,
}
except Exception as exc:
- logger.warning(f"[ERC8183JobOps] get_response({job_id}) on-chain fallback failed: {exc}")
+ logger.warning(
+ f"[ERC8183JobOps] get_response({job_id}) on-chain fallback failed: {exc}"
+ )
# A job that has been submitted on-chain MUST have a JobInitialised
# event, so failing to resolve its URL above (rate-limited RPC,
@@ -475,9 +484,7 @@ async def verify_job(self, job_id: int) -> dict[str, Any]:
# agent doesn't keep retrying every funded-poll tick on a job whose
# submit deadline has already passed.
try:
- dispute_window = await asyncio.to_thread(
- self._get_client().policy.dispute_window
- )
+ dispute_window = await asyncio.to_thread(self._get_client().policy.dispute_window)
submit_deadline = expired_at - int(dispute_window)
if now > submit_deadline:
return {
@@ -550,7 +557,9 @@ async def verify_job(self, job_id: int) -> dict[str, Any]:
}
except Exception as exc:
logger.error(f"[ERC8183JobOps] verify_job({job_id}) failed: {exc}")
- is_net = any(k in str(exc).lower() for k in ("timeout", "connection", "network", "rpc"))
+ is_net = any(
+ k in str(exc).lower() for k in ("timeout", "connection", "network", "rpc")
+ )
return {
"valid": False,
"error": "Temporary chain/RPC error" if is_net else "Failed to verify job",
@@ -727,7 +736,8 @@ async def _fire(job: dict[str, Any]) -> None:
except Exception as exc:
logger.error(
"[funded_job_watcher] on_funded(%s) failed; will retry: %s",
- job_id, exc,
+ job_id,
+ exc,
)
retry.add(job_id)
return
@@ -745,9 +755,8 @@ async def _fire(job: dict[str, Any]) -> None:
fresh = await job_ops.get_job(job_id)
if not fresh.get("success"):
continue # transient read error — keep for next tick
- if (
- fresh.get("status") != JobStatus.FUNDED
- or fresh.get("expiredAt", 0) <= int(time.time())
+ if fresh.get("status") != JobStatus.FUNDED or fresh.get("expiredAt", 0) <= int(
+ time.time()
):
retry.discard(job_id) # job moved on — stop retrying
continue
@@ -760,9 +769,7 @@ async def _fire(job: dict[str, Any]) -> None:
continue
await _fire(job)
else:
- logger.warning(
- "[funded_job_watcher] poll error: %s", result.get("error")
- )
+ logger.warning("[funded_job_watcher] poll error: %s", result.get("error"))
except Exception as exc:
logger.error("[funded_job_watcher] iteration failed: %s", exc)
@@ -774,4 +781,3 @@ async def _fire(job: dict[str, Any]) -> None:
continue
else:
await asyncio.sleep(interval)
-
diff --git a/python/bnbagent/erc8183/negotiation.py b/python/bnbagent/erc8183/negotiation.py
index 6a94928..ce7cd8a 100644
--- a/python/bnbagent/erc8183/negotiation.py
+++ b/python/bnbagent/erc8183/negotiation.py
@@ -38,7 +38,7 @@
import json
import logging
import time
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from typing import TYPE_CHECKING
logger = logging.getLogger(__name__)
@@ -58,8 +58,9 @@ class DescriptionTooLongError(ValueError):
if TYPE_CHECKING:
- from .client import ERC8183Client
from ..wallets.protocols import MessageSigner
+ from .client import ERC8183Client
+ from .schema import JobDescription
class ReasonCode:
@@ -390,8 +391,14 @@ def _build_description_content(
if success_criteria:
terms["success_criteria"] = [_sanitize_for_claim(c) for c in success_criteria]
- negotiated_at = negotiation_result.get("negotiated_at") or response.get("negotiated_at") or int(time.time())
- quote_expires_at = negotiation_result.get("quote_expires_at") or response.get("quote_expires_at")
+ negotiated_at = (
+ negotiation_result.get("negotiated_at")
+ or response.get("negotiated_at")
+ or int(time.time())
+ )
+ quote_expires_at = negotiation_result.get("quote_expires_at") or response.get(
+ "quote_expires_at"
+ )
content: dict = {
"version": 1,
@@ -471,7 +478,7 @@ def build_job_description(
return description
-def parse_job_description(description: str) -> "JobDescription | None":
+def parse_job_description(description: str) -> JobDescription | None:
"""Parse a structured on-chain job description (schema v1+).
Returns a ``JobDescription`` if the description is a valid structured JSON,
@@ -481,6 +488,7 @@ def parse_job_description(description: str) -> "JobDescription | None":
description: The job.description string from on-chain.
"""
from .schema import JobDescription
+
return JobDescription.from_str(description)
@@ -531,7 +539,8 @@ def __init__(
Initialize the negotiation handler.
Args:
- service_price: Price in token smallest unit (e.g., "20000000000000000000" for 20 tokens)
+ service_price: Price in token smallest unit
+ (e.g., "20000000000000000000" for 20 tokens)
currency: BEP20 token contract address
estimated_completion_seconds: Estimated time to complete the service
require_quality_standards: Whether to require quality_standards in request
diff --git a/python/bnbagent/erc8183/policy.py b/python/bnbagent/erc8183/policy.py
index ff2b4a3..b61f7eb 100644
--- a/python/bnbagent/erc8183/policy.py
+++ b/python/bnbagent/erc8183/policy.py
@@ -17,8 +17,6 @@
import logging
from typing import Any
-logger = logging.getLogger(__name__)
-
from web3 import Web3
from web3.contract import Contract
@@ -29,6 +27,8 @@
from ..wallets.wallet_provider import WalletProvider
from .types import Verdict
+logger = logging.getLogger(__name__)
+
def _load_abi() -> list:
return load_abi("OptimisticPolicy.json")
@@ -125,7 +125,7 @@ def get_deliverable_url(self, job_id: int, *, hint_block: int | None = None) ->
``hint_block`` via Commerce's ``JobSubmitted`` event. If called directly
without ``hint_block`` a 1 000-block fallback window is used.
"""
- _TIGHT = 10 # blocks either side when hint is known
+ _TIGHT = 10 # blocks either side when hint is known
_FALLBACK = 1_000
try:
@@ -159,7 +159,9 @@ def get_deliverable_url(self, job_id: int, *, hint_block: int | None = None) ->
f"JobInitialised scan for job {job_id} hit the RPC "
f"range/rate limit; retry later"
) from exc
- logger.warning("[PolicyClient] get_deliverable_url(%s) event query failed: %s", job_id, exc)
+ logger.warning(
+ "[PolicyClient] get_deliverable_url(%s) event query failed: %s", job_id, exc
+ )
return None
if not logs:
@@ -188,9 +190,7 @@ def dispute_quorum_snapshot(self, job_id: int) -> int:
the snapshot is the threshold ``check`` will use, even if an admin
later calls ``setQuorum`` — protects pending disputes from
retroactive admin adjustments."""
- return self._call_with_retry(
- self.contract.functions.disputeQuorumSnapshot(job_id)
- )
+ return self._call_with_retry(self.contract.functions.disputeQuorumSnapshot(job_id))
def active_voter_count(self) -> int:
return self._call_with_retry(self.contract.functions.activeVoterCount())
diff --git a/python/bnbagent/erc8183/schema.py b/python/bnbagent/erc8183/schema.py
index a4083ef..68f5f97 100644
--- a/python/bnbagent/erc8183/schema.py
+++ b/python/bnbagent/erc8183/schema.py
@@ -102,9 +102,9 @@ def from_dict(cls, d: dict[str, Any]) -> DeliverableManifest:
response = d["response"]
if "content" not in response:
raise ValueError("DeliverableManifest.response must contain 'content'")
- for field in ("job_id", "chain_id", "contracts"):
- if field not in d:
- raise ValueError(f"DeliverableManifest missing required field: '{field}'")
+ for field_name in ("job_id", "chain_id", "contracts"):
+ if field_name not in d:
+ raise ValueError(f"DeliverableManifest missing required field: '{field_name}'")
return cls(
version=version,
job_id=d["job_id"],
@@ -180,18 +180,14 @@ def from_dict(cls, d: dict[str, Any]) -> JobDescription:
negotiated_at = d["negotiated_at"]
if not isinstance(negotiated_at, int) or isinstance(negotiated_at, bool):
- raise ValueError(
- f"negotiated_at must be int, got {type(negotiated_at).__name__}"
- )
+ raise ValueError(f"negotiated_at must be int, got {type(negotiated_at).__name__}")
quote_expires_at = d.get("quote_expires_at")
if quote_expires_at is not None and (
- not isinstance(quote_expires_at, int)
- or isinstance(quote_expires_at, bool)
+ not isinstance(quote_expires_at, int) or isinstance(quote_expires_at, bool)
):
raise ValueError(
- f"quote_expires_at must be int or null, "
- f"got {type(quote_expires_at).__name__}"
+ f"quote_expires_at must be int or null, got {type(quote_expires_at).__name__}"
)
return cls(
diff --git a/python/bnbagent/networks/__init__.py b/python/bnbagent/networks/__init__.py
index 6d54072..d080fdc 100644
--- a/python/bnbagent/networks/__init__.py
+++ b/python/bnbagent/networks/__init__.py
@@ -20,9 +20,9 @@
BNB_CHAIN_ADDRESSES,
BSC_MAINNET_CHAIN_ID,
BSC_TESTNET_CHAIN_ID,
- DeployedAddresses,
PAYMENT_TOKEN_EIP712_NAME,
PAYMENT_TOKEN_EIP712_VERSION,
+ DeployedAddresses,
get_address,
known_payment_tokens,
)
diff --git a/python/bnbagent/signing/policy.py b/python/bnbagent/signing/policy.py
index 515e1ce..a2f3d12 100644
--- a/python/bnbagent/signing/policy.py
+++ b/python/bnbagent/signing/policy.py
@@ -35,10 +35,12 @@
# at github.com/Uniswap/permit2 (allowance-transfer: PermitSingle/PermitBatch;
# signature-transfer: PermitTransferFrom/PermitBatchTransferFrom).
-EIP3009_TYPES: frozenset[str] = frozenset({
- "TransferWithAuthorization",
- "ReceiveWithAuthorization",
-})
+EIP3009_TYPES: frozenset[str] = frozenset(
+ {
+ "TransferWithAuthorization",
+ "ReceiveWithAuthorization",
+ }
+)
# Field-shape of both EIP-3009 authorization structs (name, solidity type),
# in declaration order. EIP-712 hashes the field names, types *and* order into
@@ -54,21 +56,25 @@
("nonce", "bytes32"),
)
-PERMIT_UNBOUNDED_TYPES: frozenset[str] = frozenset({
- "Permit", # EIP-2612 — long-lived allowance to a spender
- "PermitSingle", # Permit2 AllowanceTransfer — long-lived allowance
- "PermitBatch", # Permit2 AllowanceTransfer (batch)
-})
+PERMIT_UNBOUNDED_TYPES: frozenset[str] = frozenset(
+ {
+ "Permit", # EIP-2612 — long-lived allowance to a spender
+ "PermitSingle", # Permit2 AllowanceTransfer — long-lived allowance
+ "PermitBatch", # Permit2 AllowanceTransfer (batch)
+ }
+)
# Permit2 SignatureTransfer family — opt-in only, NOT in default allowlist.
# These are *safer* than the unbounded family because the spender contract
# binds (to, requestedAmount) at call time, but full enforcement requires
# witness validation we don't yet do. Callers explicitly extend the policy
# to use these.
-PERMIT2_SIGNATURE_TRANSFER_TYPES: frozenset[str] = frozenset({
- "PermitTransferFrom",
- "PermitBatchTransferFrom",
-})
+PERMIT2_SIGNATURE_TRANSFER_TYPES: frozenset[str] = frozenset(
+ {
+ "PermitTransferFrom",
+ "PermitBatchTransferFrom",
+ }
+)
@dataclass(frozen=True)
@@ -112,7 +118,7 @@ class SigningPolicy:
# ── Factory presets ────────────────────────────────────────────────
@classmethod
- def strict_default(cls) -> "SigningPolicy":
+ def strict_default(cls) -> SigningPolicy:
"""Recommended fail-closed default for direct-SDK callers.
Defaults are deliberately narrow:
@@ -141,12 +147,17 @@ def strict_default(cls) -> "SigningPolicy":
#: Environment values (case-insensitive) that block ``permissive()``
#: construction unless the caller passes ``allow_in_production=True``.
- PRODUCTION_ENV_MARKERS: frozenset[str] = frozenset({
- "prod", "production", "live", "mainnet-prod",
- })
+ PRODUCTION_ENV_MARKERS: frozenset[str] = frozenset(
+ {
+ "prod",
+ "production",
+ "live",
+ "mainnet-prod",
+ }
+ )
@classmethod
- def permissive(cls, *, allow_in_production: bool = False) -> "SigningPolicy":
+ def permissive(cls, *, allow_in_production: bool = False) -> SigningPolicy:
"""⚠️ Testing-only escape: allow_unknown_domain=True and empty deny/allow.
Refuses to construct when ``ENV`` or ``ENVIRONMENT`` env vars indicate
@@ -175,7 +186,9 @@ def permissive(cls, *, allow_in_production: bool = False) -> "SigningPolicy":
logger.warning(
"SigningPolicy.permissive() in use — POLICY DISABLED. "
"This bypasses ALL signing guards; only acceptable in tests. "
- "(env=%r, allow_in_production=%s)", env_raw, allow_in_production,
+ "(env=%r, allow_in_production=%s)",
+ env_raw,
+ allow_in_production,
)
return cls(
domain_allowlist=frozenset(),
@@ -197,7 +210,7 @@ def extend(
max_validity_window_seconds: int | None = None,
max_future_validity_seconds: int | None = None,
allow_unknown_domain: bool | None = None,
- ) -> "SigningPolicy":
+ ) -> SigningPolicy:
"""Return a new policy with extended/overridden fields.
Set-like arguments are *unioned* with the current value (additive).
@@ -205,9 +218,7 @@ def extend(
"""
kwargs: dict[str, Any] = {}
if domain_allowlist is not None:
- kwargs["domain_allowlist"] = self.domain_allowlist | frozenset(
- domain_allowlist
- )
+ kwargs["domain_allowlist"] = self.domain_allowlist | frozenset(domain_allowlist)
if primary_type_allowlist is not None:
kwargs["primary_type_allowlist"] = self.primary_type_allowlist | frozenset(
primary_type_allowlist
@@ -218,8 +229,7 @@ def extend(
)
if validity_required_primary_types is not None:
kwargs["validity_required_primary_types"] = (
- self.validity_required_primary_types
- | frozenset(validity_required_primary_types)
+ self.validity_required_primary_types | frozenset(validity_required_primary_types)
)
if max_validity_window_seconds is not None:
kwargs["max_validity_window_seconds"] = max_validity_window_seconds
@@ -238,21 +248,17 @@ def to_dict(self) -> dict[str, Any]:
nested lists (TOML-friendly). Round-trips via :meth:`from_dict`.
"""
return {
- "domain_allowlist": sorted(
- [list(pair) for pair in self.domain_allowlist]
- ),
+ "domain_allowlist": sorted([list(pair) for pair in self.domain_allowlist]),
"primary_type_allowlist": sorted(self.primary_type_allowlist),
"primary_type_denylist": sorted(self.primary_type_denylist),
- "validity_required_primary_types": sorted(
- self.validity_required_primary_types
- ),
+ "validity_required_primary_types": sorted(self.validity_required_primary_types),
"max_validity_window_seconds": self.max_validity_window_seconds,
"max_future_validity_seconds": self.max_future_validity_seconds,
"allow_unknown_domain": self.allow_unknown_domain,
}
@classmethod
- def from_dict(cls, d: dict[str, Any]) -> "SigningPolicy":
+ def from_dict(cls, d: dict[str, Any]) -> SigningPolicy:
"""Reconstruct a SigningPolicy from its :meth:`to_dict` output.
Missing keys fall back to the dataclass defaults (empty sets /
@@ -269,8 +275,7 @@ def from_dict(cls, d: dict[str, Any]) -> "SigningPolicy":
for i, entry in enumerate(raw_domains):
if not isinstance(entry, (list, tuple)) or len(entry) != 2:
raise ValueError(
- f"domain_allowlist[{i}] must be a [chain_id, address] "
- f"pair, got {entry!r}"
+ f"domain_allowlist[{i}] must be a [chain_id, address] pair, got {entry!r}"
)
domain_pairs.add((int(entry[0]), str(entry[1])))
return cls(
@@ -298,12 +303,8 @@ def __str__(self) -> str:
lines.append(f" - chain_id={cid} verifyingContract={addr}")
if n_domains == 0:
lines.append(" (none)")
- lines.append(
- f" primary_type_allowlist={sorted(self.primary_type_allowlist) or '(any)'}"
- )
- lines.append(
- f" primary_type_denylist={sorted(self.primary_type_denylist) or '(none)'}"
- )
+ lines.append(f" primary_type_allowlist={sorted(self.primary_type_allowlist) or '(any)'}")
+ lines.append(f" primary_type_denylist={sorted(self.primary_type_denylist) or '(none)'}")
lines.append(
f" validity: window<={self.max_validity_window_seconds}s, "
f"future<={self.max_future_validity_seconds}s, "
diff --git a/python/bnbagent/storage/__init__.py b/python/bnbagent/storage/__init__.py
index d116b23..bfd1ed9 100644
--- a/python/bnbagent/storage/__init__.py
+++ b/python/bnbagent/storage/__init__.py
@@ -2,8 +2,8 @@
from __future__ import annotations
-from .storage_provider import StorageProvider
from .local_storage_provider import LocalStorageProvider
+from .storage_provider import StorageProvider
from .sync_utils import upload_sync
__all__ = [
diff --git a/python/bnbagent/wallets/README.md b/python/bnbagent/wallets/README.md
index 11942eb..99de6b9 100644
--- a/python/bnbagent/wallets/README.md
+++ b/python/bnbagent/wallets/README.md
@@ -92,6 +92,7 @@ There is **no shared key store** across providers - each owns its own custody, a
| `EVMWalletProvider` | `evm` | `~/.bnbagent/wallets/
.json` (Keystore V3) |
| `TWAKProvider` | `twak` | `/.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.
@@ -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", ...]}
```
@@ -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`.
diff --git a/python/bnbagent/wallets/__init__.py b/python/bnbagent/wallets/__init__.py
index e917607..c02c4ae 100644
--- a/python/bnbagent/wallets/__init__.py
+++ b/python/bnbagent/wallets/__init__.py
@@ -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
@@ -23,6 +24,7 @@
"EVMWalletProvider",
"MPCWalletProvider",
"TWAKProvider",
+ "TurnkeyWalletProvider",
"UnsupportedWalletOperation",
"WalletIdentityMismatch",
"materialize_twak_home",
diff --git a/python/bnbagent/wallets/evm_wallet_provider.py b/python/bnbagent/wallets/evm_wallet_provider.py
index 877b716..e525835 100644
--- a/python/bnbagent/wallets/evm_wallet_provider.py
+++ b/python/bnbagent/wallets/evm_wallet_provider.py
@@ -13,6 +13,7 @@
from __future__ import annotations
+import inspect
import json
import logging
import os
@@ -20,13 +21,12 @@
from pathlib import Path
from typing import Any
-import inspect
-
from eth_account import Account
from eth_account.messages import encode_defunct
from eth_account.signers.local import LocalAccount
-from ..signing import SigningPolicy, check as _policy_check
+from ..signing import SigningPolicy
+from ..signing import check as _policy_check
from .capabilities import CALLS_ARBITRARY, PAYMASTER_SPONSOR
from .wallet_provider import WalletProvider
@@ -170,8 +170,7 @@ def _import_private_key(self, private_key: str) -> None:
if self._persist:
self._save_keystore()
logger.info(
- "Private key imported and encrypted: %s "
- "(PRIVATE_KEY can be removed from env)",
+ "Private key imported and encrypted: %s (PRIVATE_KEY can be removed from env)",
self._account.address,
)
except Exception as e:
@@ -236,7 +235,9 @@ def _save_keystore(self) -> None:
# Atomic write
fd, temp_path = tempfile.mkstemp(
- dir=self._wallets_dir, prefix=".ks_", suffix=".tmp",
+ dir=self._wallets_dir,
+ prefix=".ks_",
+ suffix=".tmp",
)
try:
with os.fdopen(fd, "w") as f:
diff --git a/python/bnbagent/wallets/factory.py b/python/bnbagent/wallets/factory.py
index 61d2101..ec14d19 100644
--- a/python/bnbagent/wallets/factory.py
+++ b/python/bnbagent/wallets/factory.py
@@ -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.
@@ -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}. "
diff --git a/python/bnbagent/wallets/turnkey/__init__.py b/python/bnbagent/wallets/turnkey/__init__.py
new file mode 100644
index 0000000..19d5889
--- /dev/null
+++ b/python/bnbagent/wallets/turnkey/__init__.py
@@ -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",
+]
diff --git a/python/bnbagent/wallets/turnkey/client.py b/python/bnbagent/wallets/turnkey/client.py
new file mode 100644
index 0000000..4dc9d7e
--- /dev/null
+++ b/python/bnbagent/wallets/turnkey/client.py
@@ -0,0 +1,214 @@
+"""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)
+ polls_remaining = _POLL_ATTEMPTS
+ while True:
+ 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,
+ )
+ if polls_remaining == 0:
+ break
+ time.sleep(_POLL_INTERVAL_S)
+ activity = self._post(
+ "/public/v1/query/get_activity",
+ {
+ "organizationId": self._organization_id,
+ "activityId": activity.get("id", ""),
+ },
+ )
+ polls_remaining -= 1
+ 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))
diff --git a/python/bnbagent/wallets/turnkey/provider.py b/python/bnbagent/wallets/turnkey/provider.py
new file mode 100644
index 0000000..76cf22c
--- /dev/null
+++ b/python/bnbagent/wallets/turnkey/provider.py
@@ -0,0 +1,473 @@
+"""Turnkey Wallet Provider — remote signing, keys in AWS Nitro Enclaves.
+
+A pure signer over Turnkey's hosted key-management API
+(https://docs.turnkey.com): the private key is generated and held inside
+Turnkey's enclave and never leaves it; every sign call is an authenticated
+HTTPS round-trip stamped by a locally held P-256 API key pair. No key
+material ever exists on this machine.
+
+Operational constraints that shaped this implementation (mirrors the
+TypeScript ``TurnkeyWalletProvider``):
+
+- **Every successful signature is billed** (free tier: 25/month at
+ 1 request/second; pay-as-you-go $0.10/signature). All client-side guards
+ (SigningPolicy, chain-id pinning, 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 must use a non-root API user plus an explicit
+ ALLOW policy; this cannot be detected client-side.
+- **Broadcast is not included** (managed broadcast is a paid feature). The
+ provider only signs; the default ``LocalExecutor`` broadcasts over the
+ SDK's own RPC, and the MegaFuel paymaster path works unchanged.
+- **EIP-712 payloads are built here, domain included.** The full typed-data
+ JSON goes to the enclave (``PAYLOAD_ENCODING_EIP712``), where server-side
+ policies can filter on domain / primary type / message. Unlike the
+ TypeScript path through ``@turnkey/viem`` (which silently serializes a
+ missing ``EIP712Domain`` type as ``{}``), this module constructs the
+ payload itself and always includes the full ``EIP712Domain`` entry — the
+ same trap, immunized by construction and pinned by tests.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+from typing import Any
+
+import rlp
+from eth_account.messages import defunct_hash_message, encode_typed_data
+from eth_utils import keccak, to_checksum_address
+from hexbytes import HexBytes
+
+from ...signing import SigningPolicy, infer_primary_type
+from ...signing import check as _policy_check
+from ..capabilities import CALLS_ARBITRARY, PAYMASTER_SPONSOR
+from ..wallet_provider import WalletProvider
+from .client import TURNKEY_API_BASE_URL_DEFAULT, TurnkeyClient
+
+# JSON numbers above 2**53-1 lose precision in double-based parsers, so big
+# uint256 values are serialized as decimal strings (the enclave accepts
+# both; ``@turnkey/viem`` does the same for JS bigints).
+_JSON_SAFE_INT_MAX = 2**53 - 1
+
+# EIP-712 canonical domain field order; only fields present in the domain
+# are included (mirrors viem's ``getTypesForEIP712Domain``).
+_EIP712_DOMAIN_FIELDS: tuple[tuple[str, str], ...] = (
+ ("name", "string"),
+ ("version", "string"),
+ ("chainId", "uint256"),
+ ("verifyingContract", "address"),
+ ("salt", "bytes32"),
+)
+
+
+def _map_vendor_error(op: str, error: Exception) -> Exception:
+ """Rewrite recognizable Turnkey API failures into actionable errors.
+
+ Detection is string/shape-based (same heuristics as the TypeScript
+ provider); unrecognized errors pass through untouched.
+ """
+ message = str(error)
+ lowered = message.lower()
+ status = getattr(error, "status_code", None)
+ if "quota" in lowered:
+ return RuntimeError(
+ f"Turnkey signature quota exhausted during {op} (free tier: 25 "
+ "billed signatures/month; pay-as-you-go $0.10/signature) — check "
+ "the Turnkey billing dashboard."
+ )
+ if status == 429 or re.search(r"rate.?limit", message, re.IGNORECASE):
+ return RuntimeError(
+ f"Turnkey rate limit hit during {op} (free tier allows 1 "
+ "request/second) — pace calls or upgrade the plan."
+ )
+ if "policy" in lowered and ("denied" in lowered or "reject" in lowered):
+ return RuntimeError(
+ f"Turnkey server-side policy denied {op} — verify the API user "
+ "has an explicit ALLOW policy covering this operation (non-root "
+ "users are default-deny)."
+ )
+ return error
+
+
+def _json_safe(value: Any) -> Any:
+ """Make a typed-data value JSON-serializable without changing its hash.
+
+ Ints beyond the double-precision range become decimal strings; bytes
+ become 0x-hex strings (EIP-712 encoders treat all three spellings
+ identically).
+ """
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, int):
+ return value if -_JSON_SAFE_INT_MAX <= value <= _JSON_SAFE_INT_MAX else str(value)
+ if isinstance(value, (bytes, bytearray)):
+ return "0x" + bytes(value).hex()
+ if isinstance(value, dict):
+ return {key: _json_safe(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_json_safe(item) for item in value]
+ return value
+
+
+def _eip712_domain_types(domain: dict[str, Any]) -> list[dict[str, str]]:
+ """Field descriptors for the ``EIP712Domain`` struct (present keys only)."""
+ return [
+ {"name": name, "type": type_}
+ for name, type_ in _EIP712_DOMAIN_FIELDS
+ if domain.get(name) is not None
+ ]
+
+
+# ── Unsigned-transaction serialization (legacy + EIP-1559) ────────────────
+#
+# Hand-rolled over the ``rlp`` package (a stable eth-account transitive
+# dependency) instead of eth_account's private ``_utils`` modules, whose
+# paths shift between releases. Only the two shapes the SDK produces are
+# supported; correctness is pinned by round-trip unit tests and the live
+# E2E (the enclave parses these bytes).
+
+
+def _int_field(tx: dict[str, Any], key: str, default: int | None = None) -> int:
+ value = tx.get(key, default)
+ if value is None:
+ raise ValueError(f"transaction is missing required field {key!r}")
+ return int(value)
+
+
+def _address_bytes(value: Any) -> bytes:
+ if value in (None, "", "0x"):
+ return b"" # contract creation
+ return bytes.fromhex(str(value)[2:] if str(value).startswith("0x") else str(value))
+
+
+def _data_bytes(value: Any) -> bytes:
+ if value in (None, "", "0x"):
+ return b""
+ text = str(value)
+ return bytes.fromhex(text[2:] if text.startswith("0x") else text)
+
+
+def _serialize_unsigned_transaction(tx: dict[str, Any]) -> bytes:
+ """Serialize a legacy or EIP-1559 transaction dict to unsigned bytes.
+
+ Type selection mirrors viem: presence of ``maxFeePerGas`` /
+ ``maxPriorityFeePerGas`` selects EIP-1559, otherwise legacy
+ (``gasPrice``). Access lists and blob fields are not supported.
+ """
+ chain_id = _int_field(tx, "chainId")
+ nonce = _int_field(tx, "nonce")
+ gas = _int_field(tx, "gas")
+ to = _address_bytes(tx.get("to"))
+ value = _int_field(tx, "value", 0)
+ data = _data_bytes(tx.get("data"))
+ if tx.get("accessList"):
+ raise ValueError("the turnkey provider does not support accessList transactions")
+
+ if "maxFeePerGas" in tx or "maxPriorityFeePerGas" in tx:
+ max_priority = _int_field(tx, "maxPriorityFeePerGas")
+ max_fee = _int_field(tx, "maxFeePerGas")
+ return b"\x02" + rlp.encode(
+ [chain_id, nonce, max_priority, max_fee, gas, to, value, data, []]
+ )
+
+ gas_price = _int_field(tx, "gasPrice")
+ # Unsigned EIP-155 payload: the chain id takes the signature slots.
+ return rlp.encode([nonce, gas_price, gas, to, value, data, chain_id, 0, 0])
+
+
+def _decode_int(value: bytes) -> int:
+ return int.from_bytes(value, "big") if value else 0
+
+
+def _parse_signed_transaction(raw: bytes) -> dict[str, int]:
+ """Extract ``r``/``s``/``v`` from a signed legacy or EIP-1559 RLP.
+
+ ``v`` follows eth-account semantics: the EIP-155 value for legacy
+ transactions, the y-parity bit (0/1) for typed transactions.
+ """
+ if raw[:1] == b"\x02":
+ fields = rlp.decode(raw[1:])
+ if len(fields) != 12:
+ raise ValueError(f"unexpected EIP-1559 transaction shape ({len(fields)} fields)")
+ return {
+ "v": _decode_int(fields[9]),
+ "r": _decode_int(fields[10]),
+ "s": _decode_int(fields[11]),
+ }
+ fields = rlp.decode(raw)
+ if len(fields) != 9:
+ raise ValueError(f"unexpected legacy transaction shape ({len(fields)} fields)")
+ return {
+ "v": _decode_int(fields[6]),
+ "r": _decode_int(fields[7]),
+ "s": _decode_int(fields[8]),
+ }
+
+
+class TurnkeyWalletProvider(WalletProvider):
+ """Wallet provider backed by Turnkey's remote enclave signing service.
+
+ A pure signer: implements the three ``sign_*`` methods (capabilities
+ derive automatically) and inherits the default ``LocalExecutor`` path,
+ so ERC-8004 / ERC-8183 writes, x402 payments (via ``X402Signer``) and
+ MegaFuel sponsorship all work without Turnkey-specific wiring.
+
+ Construction is cheap and offline — the HTTP client (and its optional
+ ``cryptography`` dependency) is built lazily on the first sign call.
+
+ Args:
+ organization_id: Turnkey organization id (dashboard → settings).
+ sign_with: The wallet account to sign with — MUST be the account's
+ Ethereum address (``0x`` + 40 hex chars), not a Turnkey wallet
+ id or private-key id.
+ api_public_key: Compressed P-256 API public key (dashboard → API keys).
+ api_private_key: P-256 API private key hex. A client credential —
+ never leaves this process.
+ api_base_url: API host override (default ``https://api.turnkey.com``).
+ expected_chain_id: When set, :meth:`sign_transaction` refuses any
+ transaction whose ``chainId`` differs — fail-closed BEFORE the
+ billable API call.
+ signing_policy: Policy applied to every :meth:`sign_typed_data`
+ call, BEFORE the billable API call. Defaults to
+ :meth:`SigningPolicy.strict_default`. This client-side gate is
+ the first of two layers — Turnkey's server-side policy engine is
+ the second (and is bypassed entirely for root API users).
+ client: Test seam — a pre-built object with the
+ :class:`~bnbagent.wallets.turnkey.client.TurnkeyClient` surface.
+ """
+
+ kind = "turnkey"
+ # Arbitrary mechanical contract calls via LocalExecutor; sponsored
+ # broadcast via the MegaFuel paymaster (gasPrice=0 legacy signing
+ # verified against the enclave, probe 2026-07-27). sign.* derive
+ # automatically since all three sign methods below are overridden.
+ _extra_capabilities = frozenset({CALLS_ARBITRARY, PAYMASTER_SPONSOR})
+
+ def __init__(
+ self,
+ *,
+ organization_id: str,
+ sign_with: str,
+ api_public_key: str,
+ api_private_key: str,
+ api_base_url: str | None = None,
+ expected_chain_id: int | None = None,
+ signing_policy: SigningPolicy | None = None,
+ client: TurnkeyClient | None = None,
+ ) -> None:
+ for name, value in (
+ ("organization_id", organization_id),
+ ("sign_with", sign_with),
+ ("api_public_key", api_public_key),
+ ("api_private_key", api_private_key),
+ ):
+ if not value:
+ raise ValueError(f"TurnkeyWalletProvider: {name!r} is required")
+ if len(sign_with) != 42 or not sign_with.startswith("0x"):
+ raise ValueError(
+ "TURNKEY_SIGN_WITH must be the wallet account's Ethereum "
+ "address (0x + 40 hex chars), not a Turnkey wallet id or "
+ "private-key id — copy the address from the Turnkey dashboard "
+ f"wallet-account view. Got: {sign_with!r}"
+ )
+ try:
+ self._address = to_checksum_address(sign_with)
+ except ValueError as exc:
+ raise ValueError(
+ f"TURNKEY_SIGN_WITH is not a valid Ethereum address: {sign_with!r}"
+ ) from exc
+ self._organization_id = organization_id
+ self._api_public_key = api_public_key
+ self._api_private_key = api_private_key
+ self._api_base_url = api_base_url or TURNKEY_API_BASE_URL_DEFAULT
+ self._expected_chain_id = expected_chain_id
+ self._signing_policy = signing_policy or SigningPolicy.strict_default()
+ self._client = client
+
+ @classmethod
+ def from_env(
+ cls,
+ *,
+ expected_chain_id: int | None = None,
+ signing_policy: SigningPolicy | None = None,
+ ) -> TurnkeyWalletProvider:
+ """Build a provider from the ``TURNKEY_*`` environment variables.
+
+ Required: ``TURNKEY_API_PUBLIC_KEY``, ``TURNKEY_API_PRIVATE_KEY``,
+ ``TURNKEY_ORG_ID``, ``TURNKEY_SIGN_WITH``. Optional:
+ ``TURNKEY_API_BASE_URL``.
+ """
+ values = {
+ key: (os.environ.get(key) or "").strip()
+ for key in (
+ "TURNKEY_API_PUBLIC_KEY",
+ "TURNKEY_API_PRIVATE_KEY",
+ "TURNKEY_ORG_ID",
+ "TURNKEY_SIGN_WITH",
+ )
+ }
+ missing = [key for key, value in values.items() if not value]
+ if missing:
+ raise ValueError(
+ "TurnkeyWalletProvider.from_env: missing required env vars: "
+ f"{', '.join(missing)}. The values come from the Turnkey "
+ "dashboard (API keys, organization settings, wallet account "
+ "address)."
+ )
+ return cls(
+ api_public_key=values["TURNKEY_API_PUBLIC_KEY"],
+ api_private_key=values["TURNKEY_API_PRIVATE_KEY"],
+ organization_id=values["TURNKEY_ORG_ID"],
+ sign_with=values["TURNKEY_SIGN_WITH"],
+ api_base_url=os.environ.get("TURNKEY_API_BASE_URL") or None,
+ expected_chain_id=expected_chain_id,
+ signing_policy=signing_policy,
+ )
+
+ # ── Introspection ─────────────────────────────────────────────────
+
+ @property
+ def address(self) -> str:
+ return self._address
+
+ @property
+ def key_location(self) -> str:
+ return (
+ f"remote:turnkey ({self._api_base_url}; key held in AWS Nitro enclave, never leaves)"
+ )
+
+ @property
+ def signing_policy(self) -> SigningPolicy:
+ """The SigningPolicy currently enforcing sign_typed_data calls."""
+ return self._signing_policy
+
+ @property
+ def expected_chain_id(self) -> int | None:
+ """The chain id this provider is pinned to, if any."""
+ return self._expected_chain_id
+
+ # ── Signing ───────────────────────────────────────────────────────
+
+ def _get_client(self) -> TurnkeyClient:
+ if self._client is None:
+ self._client = TurnkeyClient(
+ api_base_url=self._api_base_url,
+ api_public_key=self._api_public_key,
+ api_private_key=self._api_private_key,
+ organization_id=self._organization_id,
+ )
+ return self._client
+
+ def _sign_digest_blind(self, op: str, digest: bytes) -> dict[str, Any]:
+ """Blind-sign a 32-byte digest (``HEXADECIMAL`` + ``NO_OP``)."""
+ return self._raw_payload_signature(
+ op,
+ payload="0x" + digest.hex(),
+ encoding="PAYLOAD_ENCODING_HEXADECIMAL",
+ )
+
+ def _raw_payload_signature(self, op: str, *, payload: str, encoding: str) -> dict[str, Any]:
+ client = self._get_client()
+ try:
+ result = client.sign_raw_payload(
+ sign_with=self._address,
+ payload=payload,
+ encoding=encoding,
+ hash_function="HASH_FUNCTION_NO_OP",
+ )
+ except Exception as exc: # noqa: BLE001 - mapped and re-raised
+ raise _map_vendor_error(op, exc) from exc
+ r = int(result["r"], 16)
+ s = int(result["s"], 16)
+ # The API returns the recovery id ("00"/"01"); normalize to 27/28.
+ v = int(result["v"], 16) + 27
+ signature = HexBytes(r.to_bytes(32, "big") + s.to_bytes(32, "big") + bytes([v]))
+ return {"r": r, "s": s, "v": v, "signature": signature}
+
+ def sign_message(self, message: str) -> dict[str, Any]:
+ """Sign a message using EIP-191 personal sign.
+
+ The digest is hashed locally and blind-signed by the enclave, so
+ Turnkey's server-side policies cannot see the message content —
+ content-level control lives in this SDK's client-side policy layer.
+ """
+ digest = defunct_hash_message(text=message)
+ signed = self._sign_digest_blind("sign_message", bytes(digest))
+ return {"messageHash": HexBytes(digest), **signed}
+
+ def sign_typed_data(
+ self,
+ domain: dict[str, Any],
+ types: dict[str, list[dict[str, str]]],
+ message: dict[str, Any],
+ ) -> dict[str, Any]:
+ """Sign EIP-712 typed data after passing the configured SigningPolicy.
+
+ The policy check runs BEFORE the billable API call, so a refusal
+ costs no quota. The full typed-data document (domain included) goes
+ to the enclave, where server-side policies can filter on
+ ``eth.eip_712.domain`` / ``primary_type`` / ``message``.
+ """
+ _policy_check(self._signing_policy, domain, types, message)
+ message_types = {k: v for k, v in types.items() if k != "EIP712Domain"}
+ primary_type = infer_primary_type(types)
+
+ # Local digest for the SignatureResult (and signature verification).
+ signable = encode_typed_data(
+ domain_data=domain, message_types=message_types, message_data=message
+ )
+ digest = keccak(b"\x19" + signable.version + signable.header + signable.body)
+
+ payload = json.dumps(
+ {
+ "types": {
+ "EIP712Domain": _eip712_domain_types(domain),
+ **_json_safe(message_types),
+ },
+ "domain": _json_safe(domain),
+ "primaryType": primary_type,
+ "message": _json_safe(message),
+ },
+ separators=(",", ":"),
+ )
+ signed = self._raw_payload_signature(
+ "sign_typed_data",
+ payload=payload,
+ encoding="PAYLOAD_ENCODING_EIP712",
+ )
+ return {"messageHash": HexBytes(digest), **signed}
+
+ def sign_transaction(self, transaction: dict[str, Any]) -> dict[str, Any]:
+ """Sign a legacy or EIP-1559 transaction via the enclave.
+
+ When the provider was constructed with ``expected_chain_id``, a
+ mismatching ``chainId`` is refused before the billable API call.
+ """
+ chain_id = transaction.get("chainId")
+ if self._expected_chain_id is not None and chain_id != self._expected_chain_id:
+ raise ValueError(
+ f"Refusing to sign for chainId={chain_id}: this Turnkey "
+ f"provider is pinned to chainId={self._expected_chain_id} "
+ "(every Turnkey signature is billed, so the mismatch fails "
+ "closed before the API call)."
+ )
+ unsigned = _serialize_unsigned_transaction(transaction)
+ client = self._get_client()
+ try:
+ signed_hex = client.sign_transaction(
+ sign_with=self._address,
+ unsigned_transaction=unsigned.hex(),
+ )
+ except Exception as exc: # noqa: BLE001 - mapped and re-raised
+ raise _map_vendor_error("sign_transaction", exc) from exc
+ raw = HexBytes(signed_hex)
+ parsed = _parse_signed_transaction(bytes(raw))
+ return {
+ "rawTransaction": raw,
+ "hash": HexBytes(keccak(bytes(raw))),
+ "r": parsed["r"],
+ "s": parsed["s"],
+ "v": parsed["v"],
+ }
diff --git a/python/bnbagent/wallets/turnkey/stamper.py b/python/bnbagent/wallets/turnkey/stamper.py
new file mode 100644
index 0000000..1d63c25
--- /dev/null
+++ b/python/bnbagent/wallets/turnkey/stamper.py
@@ -0,0 +1,99 @@
+"""Turnkey API-key request stamping (X-Stamp header).
+
+Turnkey authenticates every request by a signature over the exact JSON body:
+the caller signs the raw bytes with a locally held P-256 API key (ECDSA /
+SHA-256, DER-encoded), wraps it as
+``{"publicKey", "scheme": "SIGNATURE_SCHEME_TK_API_P256", "signature"}`` and
+sends it base64url-encoded (padding stripped) in the ``X-Stamp`` header.
+Mirrors ``@turnkey/api-key-stamper`` byte-for-byte; the private key never
+leaves this process.
+
+The P-256 primitive comes from the ``cryptography`` package, an **optional
+extra** (``pip install 'bnbagent[turnkey]'``) imported lazily at first use —
+mirroring the TypeScript side's optional-peer semantics, so ``import
+bnbagent`` never requires it.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+
+STAMP_HEADER_NAME = "X-Stamp"
+_SIGNATURE_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"
+
+_INSTALL_HINT = (
+ "The Turnkey wallet provider requires the optional 'cryptography' "
+ "dependency (not installed). Install it with: pip install 'bnbagent[turnkey]'"
+)
+
+
+def _load_ec():
+ """Import the P-256 primitives lazily, with an actionable error."""
+ try:
+ from cryptography.hazmat.primitives import hashes
+ from cryptography.hazmat.primitives.asymmetric import ec
+ except ImportError as exc: # pragma: no cover - exercised via unit test
+ raise RuntimeError(_INSTALL_HINT) from exc
+ return ec, hashes
+
+
+def _strip_hex(value: str) -> str:
+ return value[2:] if value.startswith(("0x", "0X")) else value
+
+
+class ApiKeyStamper:
+ """Stamps request bodies with a Turnkey P-256 API key pair.
+
+ Args:
+ api_public_key: Compressed P-256 public key, hex (33 bytes / 66 hex
+ chars, ``02``/``03`` prefix).
+ api_private_key: Raw P-256 private scalar, hex (32 bytes).
+
+ Raises:
+ RuntimeError: If ``cryptography`` is not installed, or the public key
+ does not match the private key (swapped/wrong credentials fail
+ here with a clear message instead of an opaque 401).
+ """
+
+ def __init__(self, *, api_public_key: str, api_private_key: str) -> None:
+ ec, hashes = _load_ec()
+ self._hashes = hashes
+ self._ec = ec
+ self._api_public_key = _strip_hex(api_public_key).lower()
+ try:
+ self._key = ec.derive_private_key(int(_strip_hex(api_private_key), 16), ec.SECP256R1())
+ except ValueError as exc:
+ raise RuntimeError(
+ f"TURNKEY_API_PRIVATE_KEY is not a valid P-256 private key hex: {exc}"
+ ) from exc
+
+ from cryptography.hazmat.primitives.serialization import (
+ Encoding,
+ PublicFormat,
+ )
+
+ derived = self._key.public_key().public_bytes(Encoding.X962, PublicFormat.CompressedPoint)
+ if derived.hex() != self._api_public_key:
+ raise RuntimeError(
+ "TURNKEY_API_PUBLIC_KEY does not match TURNKEY_API_PRIVATE_KEY "
+ "(the compressed public key derived from the private key differs) "
+ "— check that both halves come from the same Turnkey API key."
+ )
+
+ def stamp(self, payload: str) -> tuple[str, str]:
+ """Sign ``payload`` (the exact request body string) for ``X-Stamp``.
+
+ Returns:
+ ``(header_name, header_value)``.
+ """
+ signature = self._key.sign(payload.encode("utf-8"), self._ec.ECDSA(self._hashes.SHA256()))
+ stamp = {
+ "publicKey": self._api_public_key,
+ "scheme": _SIGNATURE_SCHEME,
+ "signature": signature.hex(),
+ }
+ encoded = base64.urlsafe_b64encode(
+ json.dumps(stamp, separators=(",", ":")).encode("utf-8")
+ )
+ return STAMP_HEADER_NAME, encoded.decode("ascii").rstrip("=")
diff --git a/python/bnbagent/wallets/twak_provider.py b/python/bnbagent/wallets/twak_provider.py
index d6a3e41..b03b125 100644
--- a/python/bnbagent/wallets/twak_provider.py
+++ b/python/bnbagent/wallets/twak_provider.py
@@ -238,9 +238,7 @@ class TWAKProvider(WalletProvider, IntentExecutor):
# sign.message derives automatically from the override below; twak has
# no sign_transaction / sign_typed_data, so the base defaults raise.
# x402.pay: served by the delegated TwakX402Payer (make_x402_payer).
- _extra_capabilities = frozenset(
- {BROADCAST_SELF, INTENTS_ERC8004, INTENTS_ERC8183, X402_PAY}
- )
+ _extra_capabilities = frozenset({BROADCAST_SELF, INTENTS_ERC8004, INTENTS_ERC8183, X402_PAY})
def __init__(
self,
@@ -292,11 +290,7 @@ def _run(self, args: list[str]) -> dict[str, Any]:
an error.
"""
cmd = [self._twak_bin, *args, "--json"]
- env = (
- {**os.environ, "HOME": str(self._home)}
- if self._home is not None
- else None
- )
+ env = {**os.environ, "HOME": str(self._home)} if self._home is not None else None
try:
proc = subprocess.run(
cmd,
@@ -312,7 +306,9 @@ def _run(self, args: list[str]) -> dict[str, Any]:
"configure it (see TWAKProvider prerequisites)."
) from e
except subprocess.TimeoutExpired as e:
- raise RuntimeError(f"twak command timed out after {self._timeout}s: {_redact(cmd)}") from e
+ raise RuntimeError(
+ f"twak command timed out after {self._timeout}s: {_redact(cmd)}"
+ ) from e
if proc.returncode != 0:
# Quirk (field-verified v0.18.0): `x402 quote` exits non-zero on an
@@ -363,10 +359,7 @@ def _format_error(cmd: list[str], stdout: str, stderr: str) -> str:
)
else:
hint = _SETUP_HINT
- return (
- f"twak command failed ({_redact(cmd)}): {detail or ''}. "
- f"{hint}"
- )
+ return f"twak command failed ({_redact(cmd)}): {detail or ''}. {hint}"
@staticmethod
def _extract_tx_hash(data: dict[str, Any]) -> str | None:
@@ -410,16 +403,9 @@ def _lookup_address(self) -> None:
data = self._run(["wallet", "address", "--chain", self._chain])
addr = data.get("address") or data.get("wallet")
if not addr:
- raise RuntimeError(
- f"twak `wallet address` did not return an address: {data!r}"
- )
- if (
- self._expected_address is not None
- and addr.lower() != self._expected_address.lower()
- ):
- raise WalletIdentityMismatch(
- expected=self._expected_address, actual=addr
- )
+ raise RuntimeError(f"twak `wallet address` did not return an address: {data!r}")
+ if self._expected_address is not None and addr.lower() != self._expected_address.lower():
+ raise WalletIdentityMismatch(expected=self._expected_address, actual=addr)
self._address = addr
@property
@@ -578,13 +564,10 @@ def sign_message(self, message: str) -> dict[str, Any]:
signature = "0x" + signature
digest = "0x" + bytes(defunct_hash_message(text=message)).hex()
try:
- recovered = Account.recover_message(
- encode_defunct(text=message), signature=signature
- )
+ recovered = Account.recover_message(encode_defunct(text=message), signature=signature)
except Exception as e:
raise RuntimeError(
- f"twak `sign-message` returned a malformed signature "
- f"({signature[:20]}...): {e}"
+ f"twak `sign-message` returned a malformed signature ({signature[:20]}...): {e}"
) from e
if recovered.lower() != self.address.lower():
raise RuntimeError(
@@ -809,16 +792,21 @@ def _register(self, kwargs: dict[str, Any]) -> dict[str, Any]:
for entry in metadata:
args += ["--metadata", f"{entry['key']}={entry['value']}"]
data = self._run([*args, *self._paymaster_args(), "--chain", self._chain])
- return self._tx_result(
- data, agentId=_as_int(data.get("agentId")), owner=data.get("owner")
- )
+ return self._tx_result(data, agentId=_as_int(data.get("agentId")), owner=data.get("owner"))
def _set_metadata(self, kwargs: dict[str, Any]) -> dict[str, Any]:
data = self._run(
[
- "erc8004", "set-metadata", str(kwargs["agent_id"]),
- "--key", kwargs["key"], "--value", kwargs["value"],
- *self._paymaster_args(), "--chain", self._chain,
+ "erc8004",
+ "set-metadata",
+ str(kwargs["agent_id"]),
+ "--key",
+ kwargs["key"],
+ "--value",
+ kwargs["value"],
+ *self._paymaster_args(),
+ "--chain",
+ self._chain,
]
)
return self._tx_result(data)
@@ -826,9 +814,14 @@ def _set_metadata(self, kwargs: dict[str, Any]) -> dict[str, Any]:
def _set_agent_uri(self, kwargs: dict[str, Any]) -> dict[str, Any]:
data = self._run(
[
- "erc8004", "set-uri", str(kwargs["agent_id"]),
- "--uri", kwargs["agent_uri"],
- *self._paymaster_args(), "--chain", self._chain,
+ "erc8004",
+ "set-uri",
+ str(kwargs["agent_id"]),
+ "--uri",
+ kwargs["agent_uri"],
+ *self._paymaster_args(),
+ "--chain",
+ self._chain,
]
)
return self._tx_result(data)
@@ -839,19 +832,29 @@ def _erc8183(self, command: str, job_id: Any, *extra: str) -> dict[str, Any]:
"""Run ``twak erc8183 [extra...] --chain ``."""
data = self._run(
[
- "erc8183", command, str(job_id), *extra,
- *self._paymaster_args(), "--chain", self._chain,
+ "erc8183",
+ command,
+ str(job_id),
+ *extra,
+ *self._paymaster_args(),
+ "--chain",
+ self._chain,
]
)
return self._tx_result(data)
def _create_job(self, kwargs: dict[str, Any]) -> dict[str, Any]:
args = [
- "erc8183", "create-job",
- "--provider", kwargs["provider"],
- "--evaluator", kwargs["evaluator"],
- "--expires-at", str(kwargs["expired_at"]),
- "--description", kwargs["description"],
+ "erc8183",
+ "create-job",
+ "--provider",
+ kwargs["provider"],
+ "--evaluator",
+ kwargs["evaluator"],
+ "--expires-at",
+ str(kwargs["expired_at"]),
+ "--description",
+ kwargs["description"],
]
hook = kwargs.get("hook")
if hook and hook.lower() != _ZERO_ADDRESS:
@@ -865,14 +868,20 @@ def _create_job(self, kwargs: dict[str, Any]) -> dict[str, Any]:
def _set_provider(self, kwargs: dict[str, Any]) -> dict[str, Any]:
return self._erc8183(
- "set-provider", kwargs["job_id"],
- "--provider", kwargs["provider"], *self._opt_params(kwargs),
+ "set-provider",
+ kwargs["job_id"],
+ "--provider",
+ kwargs["provider"],
+ *self._opt_params(kwargs),
)
def _set_budget(self, kwargs: dict[str, Any]) -> dict[str, Any]:
return self._erc8183(
- "set-budget", kwargs["job_id"],
- "--amount", str(kwargs["amount"]), *self._opt_params(kwargs),
+ "set-budget",
+ kwargs["job_id"],
+ "--amount",
+ str(kwargs["amount"]),
+ *self._opt_params(kwargs),
)
def _fund(self, kwargs: dict[str, Any]) -> dict[str, Any]:
@@ -882,10 +891,15 @@ def _fund(self, kwargs: dict[str, Any]) -> dict[str, Any]:
# pre-check could not (gaps S-2, shipped).
data = self._run(
[
- "erc8183", "fund", str(kwargs["job_id"]),
- "--expected-budget", str(kwargs["expected_budget"]),
+ "erc8183",
+ "fund",
+ str(kwargs["job_id"]),
+ "--expected-budget",
+ str(kwargs["expected_budget"]),
*self._opt_params(kwargs),
- *self._paymaster_args(), "--chain", self._chain,
+ *self._paymaster_args(),
+ "--chain",
+ self._chain,
]
)
result = self._tx_result(data)
@@ -899,8 +913,11 @@ def _submit(self, kwargs: dict[str, Any]) -> dict[str, Any]:
# JobInitialised event — the seller role works end-to-end.
deliverable: bytes = kwargs["deliverable"]
return self._erc8183(
- "submit", kwargs["job_id"],
- "--deliverable", "0x" + deliverable.hex(), *self._opt_params(kwargs),
+ "submit",
+ kwargs["job_id"],
+ "--deliverable",
+ "0x" + deliverable.hex(),
+ *self._opt_params(kwargs),
)
def _complete(self, kwargs: dict[str, Any]) -> dict[str, Any]:
@@ -914,17 +931,13 @@ def _reason_op(self, command: str, kwargs: dict[str, Any]) -> dict[str, Any]:
reason: bytes = kwargs.get("reason") or b""
if reason and reason != _ZERO_REASON: # twak defaults --reason to zero
extra = ["--reason", "0x" + reason.hex()]
- return self._erc8183(
- command, kwargs["job_id"], *extra, *self._opt_params(kwargs)
- )
+ return self._erc8183(command, kwargs["job_id"], *extra, *self._opt_params(kwargs))
def _claim_refund(self, kwargs: dict[str, Any]) -> dict[str, Any]:
return self._erc8183("claim-refund", kwargs["job_id"])
def _register_job(self, kwargs: dict[str, Any]) -> dict[str, Any]:
- return self._erc8183(
- "register-job", kwargs["job_id"], "--policy", kwargs["policy"]
- )
+ return self._erc8183("register-job", kwargs["job_id"], "--policy", kwargs["policy"])
def _settle(self, kwargs: dict[str, Any]) -> dict[str, Any]:
extra: list[str] = []
diff --git a/python/bnbagent/wallets/wallet_provider.py b/python/bnbagent/wallets/wallet_provider.py
index 2c043cb..160d8f1 100644
--- a/python/bnbagent/wallets/wallet_provider.py
+++ b/python/bnbagent/wallets/wallet_provider.py
@@ -174,10 +174,7 @@ def make_x402_payer(self, **payer_kwargs: Any) -> X402Payer:
"""
raise UnsupportedWalletOperation(
X402_PAY,
- reason=(
- f"the {self.kind!r} wallet has no x402 payment backend in "
- "the SDK yet"
- ),
+ reason=(f"the {self.kind!r} wallet has no x402 payment backend in the SDK yet"),
alternative=(
"wallets with sign.typed_data can use X402Signer directly "
"today; a local payer that upgrades this default is planned"
@@ -207,14 +204,13 @@ def sign_transaction(self, transaction: dict[str, Any]) -> dict[str, Any]:
'gasPrice', 'nonce', 'data', 'chainId'
Returns:
- dict: Signed transaction dictionary with 'rawTransaction', 'hash', 'r', 's', 'v'
+ dict: Signed transaction dictionary with 'rawTransaction', 'hash', 'r', 's', 'v'.
+ For legacy transactions, ``v`` is the EIP-155 value; for typed
+ transactions, it is the y-parity bit (0 or 1).
"""
raise UnsupportedWalletOperation(
SIGN_TRANSACTION,
- reason=(
- f"the {self.kind!r} wallet does not implement raw-transaction "
- "signing"
- ),
+ reason=(f"the {self.kind!r} wallet does not implement raw-transaction signing"),
alternative=(
"use a wallet whose capabilities() include 'sign.transaction', "
"or route high-level operations through the wallet's own "
@@ -241,13 +237,8 @@ def sign_message(self, message: str) -> dict[str, Any]:
"""
raise UnsupportedWalletOperation(
SIGN_MESSAGE,
- reason=(
- f"the {self.kind!r} wallet does not implement EIP-191 "
- "personal-sign"
- ),
- alternative=(
- "use a wallet whose capabilities() include 'sign.message'"
- ),
+ reason=(f"the {self.kind!r} wallet does not implement EIP-191 personal-sign"),
+ alternative=("use a wallet whose capabilities() include 'sign.message'"),
)
def sign_typed_data(
@@ -307,10 +298,7 @@ def sign_typed_data(
"""
raise UnsupportedWalletOperation(
SIGN_TYPED_DATA,
- reason=(
- f"the {self.kind!r} wallet does not implement EIP-712 "
- "typed-data signing"
- ),
+ reason=(f"the {self.kind!r} wallet does not implement EIP-712 typed-data signing"),
alternative=(
"use a wallet whose capabilities() include 'sign.typed_data', "
"or a delegated flow that signs internally (e.g. the x402 "
diff --git a/python/bnbagent/x402/signer.py b/python/bnbagent/x402/signer.py
index c1cb65b..ffa17bb 100644
--- a/python/bnbagent/x402/signer.py
+++ b/python/bnbagent/x402/signer.py
@@ -30,7 +30,6 @@
from .budget import SessionBudgetTracker
from .errors import (
X402AmountExceededError,
- X402BudgetExhaustedError,
X402PolicyError,
X402RecipientMismatchError,
)
@@ -164,9 +163,7 @@ def sign_payment(
# test below and neutralises the session budget. Refuse before
# reserve() so a rejected call still costs no budget.
if value < 0:
- raise X402AmountExceededError(
- f"message['value'] must be non-negative, got {value}"
- )
+ raise X402AmountExceededError(f"message['value'] must be non-negative, got {value}")
cap = self._max_value.get(verifying)
if cap is not None and value > cap:
raise X402AmountExceededError(
@@ -221,6 +218,9 @@ def sign_payment(
logger.info(
"x402 payment signed: token=%s value=%s to=%s expected_to=%s",
- verifying, value, msg_to, expected_to,
+ verifying,
+ value,
+ msg_to,
+ expected_to,
)
return signed
diff --git a/python/examples/a2a-agent/scripts/buyer.py b/python/examples/a2a-agent/scripts/buyer.py
index 9fe8f0b..83a8863 100644
--- a/python/examples/a2a-agent/scripts/buyer.py
+++ b/python/examples/a2a-agent/scripts/buyer.py
@@ -33,7 +33,9 @@
import httpx
from dotenv import load_dotenv
-load_dotenv(Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env")))
+load_dotenv(
+ Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env"))
+)
NETWORK = os.getenv("NETWORK", "bsc-testnet")
@@ -74,7 +76,11 @@ def agent_card_url(base: str) -> str:
parts = urlsplit(base)
if not parts.scheme or not parts.netloc:
url = base.rstrip("/")
- return url if url.endswith("/.well-known/agent-card.json") else f"{url}/.well-known/agent-card.json"
+ return (
+ url
+ if url.endswith("/.well-known/agent-card.json")
+ else f"{url}/.well-known/agent-card.json"
+ )
path = parts.path.rstrip("/")
if not path.endswith("/.well-known/agent-card.json"):
path += "/.well-known/agent-card.json"
@@ -154,7 +160,9 @@ def fund_job(quote: dict) -> int | None:
from bnbagent import ERC8183Client, EVMWalletProvider
from bnbagent.erc8183.negotiation import build_job_description
- wallet = EVMWalletProvider(password=os.getenv("BUYER_WALLET_PASSWORD", "demo-password"), private_key=buyer_key)
+ wallet = EVMWalletProvider(
+ password=os.getenv("BUYER_WALLET_PASSWORD", "demo-password"), private_key=buyer_key
+ )
client = ERC8183Client(wallet_provider=wallet, network=NETWORK)
provider = quote["provider_address"]
diff --git a/python/examples/a2a-agent/scripts/register.py b/python/examples/a2a-agent/scripts/register.py
index db06742..c0d436e 100644
--- a/python/examples/a2a-agent/scripts/register.py
+++ b/python/examples/a2a-agent/scripts/register.py
@@ -22,9 +22,11 @@
from dotenv import load_dotenv
-load_dotenv(Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env")))
+load_dotenv(
+ Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env"))
+)
-from bnbagent import AgentEndpoint, ERC8004Agent, EVMWalletProvider
+from bnbagent import AgentEndpoint, ERC8004Agent, EVMWalletProvider # noqa: E402
def _make_wallet(network: str):
diff --git a/python/examples/a2a-agent/src/server.py b/python/examples/a2a-agent/src/server.py
index b6e6494..903439f 100644
--- a/python/examples/a2a-agent/src/server.py
+++ b/python/examples/a2a-agent/src/server.py
@@ -40,11 +40,13 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
-load_dotenv(Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env")))
+load_dotenv(
+ Path(__file__).resolve().parent.parent / os.path.basename(os.environ.get("ENV_FILE", ".env"))
+)
-from bnbagent import EVMWalletProvider
-from bnbagent.erc8183 import ERC8183Client, NegotiationHandler
-from bnbagent.utils import RateLimitExceeded, SlidingWindowLimiter
+from bnbagent import EVMWalletProvider # noqa: E402
+from bnbagent.erc8183 import ERC8183Client, NegotiationHandler # noqa: E402
+from bnbagent.utils import RateLimitExceeded, SlidingWindowLimiter # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s")
logger = logging.getLogger("a2a-agent")
@@ -112,8 +114,8 @@
"id": "negotiate-erc8183-job",
"name": "Negotiate an ERC-8183 job",
"description": (
- "Send a data part {\"skill\": \"negotiate-erc8183-job\", "
- "\"task_description\": \"...\", \"terms\": {...}} and receive a "
+ 'Send a data part {"skill": "negotiate-erc8183-job", '
+ '"task_description": "...", "terms": {...}} and receive a '
"wallet-signed quote (price, currency, negotiation_hash, provider_sig). "
"Anchor the returned envelope on-chain via createJob."
),
@@ -125,7 +127,7 @@
"id": "erc8183-job-status",
"name": "ERC-8183 job status",
"description": (
- "Send {\"skill\": \"erc8183-job-status\", \"job_id\": } for a "
+ 'Send {"skill": "erc8183-job-status", "job_id": } for a '
"read-only on-chain job lookup."
),
"tags": ["erc8183", "status"],
@@ -162,7 +164,11 @@ def _agent_message(data: dict[str, Any]) -> dict[str, Any]:
def _extract_data_part(message: dict[str, Any]) -> dict[str, Any] | None:
for part in message.get("parts", []):
- if isinstance(part, dict) and part.get("kind") == "data" and isinstance(part.get("data"), dict):
+ if (
+ isinstance(part, dict)
+ and part.get("kind") == "data"
+ and isinstance(part.get("data"), dict)
+ ):
return part["data"]
return None
@@ -197,7 +203,8 @@ async def a2a_endpoint(request: Request):
task_description = data.get("task_description")
if not isinstance(terms, dict) or not isinstance(task_description, str):
return _rpc_error(
- req_id, -32602,
+ req_id,
+ -32602,
"negotiate-erc8183-job requires 'task_description' (string) and 'terms' (object)",
)
try:
diff --git a/python/examples/agent-server/scripts/register.py b/python/examples/agent-server/scripts/register.py
index 610e69d..7c38753 100644
--- a/python/examples/agent-server/scripts/register.py
+++ b/python/examples/agent-server/scripts/register.py
@@ -47,16 +47,16 @@ def main():
agent_endpoint = f"{agent_host}/erc8183/status"
print(f"""
-{'='*60}
+{"=" * 60}
ERC-8004 Agent Registration
-{'='*60}
+{"=" * 60}
Name: {agent_name}
Description: {agent_description[:60]}...
Endpoint: {agent_endpoint}
""")
try:
- from bnbagent import ERC8004Agent, AgentEndpoint, EVMWalletProvider
+ from bnbagent import AgentEndpoint, ERC8004Agent, EVMWalletProvider
except ImportError:
print("Error: bnbagent SDK not installed")
print("Run: pip install bnbagent")
@@ -112,7 +112,7 @@ def main():
and agent.get("name", "").lower() == agent_name.lower()
):
existing_id = agent["token_id"]
- print(f"\n Agent already registered!")
+ print("\n Agent already registered!")
print(f" Agent ID: {existing_id}")
print(f" Name: {agent['name']}")
@@ -135,20 +135,20 @@ def main():
result = sdk.register_agent(agent_uri=agent_uri)
print(f"""
-{'='*60}
+{"=" * 60}
Registration Successful!
-{'='*60}
- Agent ID: {result['agentId']}
- TX Hash: {result['transactionHash']}
+{"=" * 60}
+ Agent ID: {result["agentId"]}
+ TX Hash: {result["transactionHash"]}
Owner: {sdk.wallet_address}
View on explorer:
- https://testnet.bscscan.com/tx/{result['transactionHash']}
+ https://testnet.bscscan.com/tx/{result["transactionHash"]}
Save this Agent ID for client configuration:
- AGENT_ID={result['agentId']}
+ AGENT_ID={result["agentId"]}
-{'='*60}
+{"=" * 60}
""")
except Exception as e:
diff --git a/python/examples/agent-server/scripts/run_agent.py b/python/examples/agent-server/scripts/run_agent.py
index 74f53fd..01d7062 100644
--- a/python/examples/agent-server/scripts/run_agent.py
+++ b/python/examples/agent-server/scripts/run_agent.py
@@ -31,6 +31,6 @@
# pass env file choice to service.py via env var before it loads dotenv
os.environ.setdefault("ENV_FILE", args.env)
- from service import app, PORT
+ from service import PORT, app
uvicorn.run(app, host="0.0.0.0", port=PORT)
diff --git a/python/examples/agent-server/scripts/run_agent_mount.py b/python/examples/agent-server/scripts/run_agent_mount.py
index 020c772..1332394 100644
--- a/python/examples/agent-server/scripts/run_agent_mount.py
+++ b/python/examples/agent-server/scripts/run_agent_mount.py
@@ -1,6 +1,7 @@
"""Run the Blockchain News Agent server (mount mode)."""
-import sys
+
import os
+import sys
# Add src to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
diff --git a/python/examples/agent-server/src/erc8183_server.py b/python/examples/agent-server/src/erc8183_server.py
index 46e67c0..e78aa48 100644
--- a/python/examples/agent-server/src/erc8183_server.py
+++ b/python/examples/agent-server/src/erc8183_server.py
@@ -117,15 +117,11 @@ def create_erc8183_state(config: ERC8183Config | None = None) -> ERC8183State:
def _build_negotiate_limiter() -> SlidingWindowLimiter:
"""Read ERC8183_NEGOTIATE_RATE_LIMIT / ERC8183_NEGOTIATE_RATE_WINDOW from env."""
raw_max = get_env("NEGOTIATE_RATE_LIMIT", "120", prefix=ERC8183_ENV_PREFIX) or "120"
- raw_window = (
- get_env("NEGOTIATE_RATE_WINDOW", "60.0", prefix=ERC8183_ENV_PREFIX) or "60.0"
- )
+ raw_window = get_env("NEGOTIATE_RATE_WINDOW", "60.0", prefix=ERC8183_ENV_PREFIX) or "60.0"
try:
max_requests = int(raw_max)
except ValueError:
- logger.warning(
- f"[ERC-8183] ERC8183_NEGOTIATE_RATE_LIMIT={raw_max!r} invalid, using 120"
- )
+ logger.warning(f"[ERC-8183] ERC8183_NEGOTIATE_RATE_LIMIT={raw_max!r} invalid, using 120")
max_requests = 120
try:
window_seconds = float(raw_window)
@@ -134,9 +130,7 @@ def _build_negotiate_limiter() -> SlidingWindowLimiter:
f"[ERC-8183] ERC8183_NEGOTIATE_RATE_WINDOW={raw_window!r} invalid, using 60.0"
)
window_seconds = 60.0
- raw_max_keys = (
- get_env("RATE_LIMIT_MAX_KEYS", "10000", prefix=ERC8183_ENV_PREFIX) or "10000"
- )
+ raw_max_keys = get_env("RATE_LIMIT_MAX_KEYS", "10000", prefix=ERC8183_ENV_PREFIX) or "10000"
try:
max_keys = int(raw_max_keys)
except ValueError:
@@ -208,7 +202,7 @@ async def negotiate(request: Request):
negotiate_limiter.check(client_ip)
except RateLimitExceeded:
# The SDK limiter is transport-agnostic; this HTTP shell maps it to 429.
- raise HTTPException(status_code=429, detail="Too many requests")
+ raise HTTPException(status_code=429, detail="Too many requests") from None
try:
body = await request.json()
@@ -216,12 +210,7 @@ async def negotiate(request: Request):
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
if not isinstance(body, dict) or "terms" not in body:
return JSONResponse(
- {
- "error": (
- "Request must include 'terms' with"
- " deliverables, quality_standards"
- )
- },
+ {"error": ("Request must include 'terms' with deliverables, quality_standards")},
status_code=400,
)
try:
@@ -371,9 +360,7 @@ def _schedule_retry(job_id: int, reason) -> None:
attempts = retry_attempts.get(job_id, 0) + 1
if attempts >= _MAX_JOB_ATTEMPTS:
retry_attempts.pop(job_id, None)
- logger.error(
- f"[ERC-8183] Job #{job_id} giving up after {attempts} attempts: {reason}"
- )
+ logger.error(f"[ERC-8183] Job #{job_id} giving up after {attempts} attempts: {reason}")
return
retry_attempts[job_id] = attempts
logger.warning(
@@ -404,16 +391,12 @@ async def _funded_poll_loop():
for job in jobs:
await _attempt_job(job["jobId"])
else:
- logger.warning(
- f"[ERC-8183] Funded-poll error: {result.get('error')}"
- )
+ logger.warning(f"[ERC-8183] Funded-poll error: {result.get('error')}")
except Exception as exc:
logger.error(f"[ERC-8183] Funded-poll iteration failed: {exc}")
try:
- await asyncio.wait_for(
- stop_event.wait(), timeout=effective_poll_interval
- )
+ await asyncio.wait_for(stop_event.wait(), timeout=effective_poll_interval)
break
except asyncio.TimeoutError:
continue
diff --git a/python/examples/agent-server/src/service.py b/python/examples/agent-server/src/service.py
index ea9f44d..b48a677 100644
--- a/python/examples/agent-server/src/service.py
+++ b/python/examples/agent-server/src/service.py
@@ -14,9 +14,12 @@
Environment (agent-server/.env):
RPC_URL, NETWORK — Required (RPC + network key)
- PRIVATE_KEY — Recommended (imported on first run; auto-generates if omitted)
+ PRIVATE_KEY — Recommended (imported on first run;
+ auto-generates if omitted)
WALLET_PASSWORD — Required (keystore password)
- ERC8183_COMMERCE_ADDRESS, ERC8183_ROUTER_ADDRESS, ERC8183_POLICY_ADDRESS — Optional overrides (defaults from NETWORK)
+ ERC8183_COMMERCE_ADDRESS, ERC8183_ROUTER_ADDRESS,
+ ERC8183_POLICY_ADDRESS — Optional overrides
+ (defaults from NETWORK)
STORAGE_API_KEY — Required for IPFS upload (when swapping to IPFSStorageProvider)
ERC8183_AGENT_URL=http://localhost:8003/erc8183 — Required for LocalStorageProvider
ERC8183_SERVICE_PRICE=1000000000000000000 — Negotiation price (1 U)
@@ -32,18 +35,19 @@
import os
from pathlib import Path
+from ddgs import DDGS
from dotenv import load_dotenv
from fastapi import HTTPException
from pydantic import BaseModel
-from ddgs import DDGS
# Load .env from project root (one level up from src/)
env_file = os.path.basename(os.environ.get("ENV_FILE", ".env"))
load_dotenv(Path(__file__).resolve().parent.parent / env_file)
# SDK imports + the example's own HTTP server factory (src/erc8183_server.py)
-from bnbagent.erc8183.config import ERC8183Config
-from erc8183_server import create_erc8183_app
+from erc8183_server import create_erc8183_app # noqa: E402
+
+from bnbagent.erc8183.config import ERC8183Config # noqa: E402
logging.basicConfig(
level=logging.INFO,
@@ -58,7 +62,8 @@
# Storage backend — pick ONE of the two options below by uncommenting it.
# (a) Local filesystem (default)
-from bnbagent.storage import LocalStorageProvider
+from bnbagent.storage import LocalStorageProvider # noqa: E402
+
_storage = LocalStorageProvider.from_env()
# (b) IPFS via Pinata — set STORAGE_API_KEY (Pinata JWT) in .env first.
@@ -92,7 +97,7 @@ def format_news_results(query: str, raw_results: list[dict]) -> str:
if not raw_results:
return f"No news found for query: {query}"
- report = f"# Blockchain News Search Results\n\n"
+ report = "# Blockchain News Search Results\n\n"
report += f"**Query:** {query}\n"
report += f"**Results:** {len(raw_results)} items\n\n"
report += "---\n\n"
@@ -157,9 +162,9 @@ def process_task(job: dict) -> tuple[str, dict]:
_storage_info = type(_storage).__name__
print(f"""
-{'='*55}
+{"=" * 55}
Blockchain News Agent (ERC-8183 Provider)
-{'='*55}
+{"=" * 55}
Port: {PORT}
Commerce: {config.effective_commerce_address}
Router: {config.effective_router_address}
@@ -175,7 +180,7 @@ def process_task(job: dict) -> tuple[str, dict]:
Direct endpoints (testing):
POST /search — Direct news search
GET /erc8183/health — Health check
-{'='*55}
+{"=" * 55}
""")
@@ -238,7 +243,7 @@ async def search_endpoint(request: SearchRequest):
)
except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
+ raise HTTPException(status_code=500, detail=str(e)) from e
# ---------------------------------------------------------------------------
diff --git a/python/examples/agent-server/src/service_mount.py b/python/examples/agent-server/src/service_mount.py
index d435ce7..e8eec9e 100644
--- a/python/examples/agent-server/src/service_mount.py
+++ b/python/examples/agent-server/src/service_mount.py
@@ -30,18 +30,19 @@
from contextlib import asynccontextmanager
from pathlib import Path
+from ddgs import DDGS
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
-from ddgs import DDGS
# Load .env from project root (one level up from src/)
env_file = os.path.basename(os.environ.get("ENV_FILE", ".env"))
load_dotenv(Path(__file__).resolve().parent.parent / env_file)
# SDK imports + the example's own HTTP server factory (src/erc8183_server.py)
-from bnbagent.erc8183.config import ERC8183Config
-from erc8183_server import create_erc8183_app
+from erc8183_server import create_erc8183_app # noqa: E402
+
+from bnbagent.erc8183.config import ERC8183Config # noqa: E402
logging.basicConfig(
level=logging.INFO,
@@ -56,7 +57,8 @@
# Storage backend — pick ONE of the three options below by uncommenting it.
# (a) Local filesystem (default)
-from bnbagent.storage import LocalStorageProvider
+from bnbagent.storage import LocalStorageProvider # noqa: E402
+
_storage = LocalStorageProvider.from_env()
# (b) IPFS via Pinata — set STORAGE_API_KEY (Pinata JWT) in .env first.
@@ -92,7 +94,7 @@ def format_news_results(query: str, raw_results: list[dict]) -> str:
if not raw_results:
return f"No news found for query: {query}"
- report = f"# Blockchain News Search Results\n\n"
+ report = "# Blockchain News Search Results\n\n"
report += f"**Query:** {query}\n"
report += f"**Results:** {len(raw_results)} items\n\n"
report += "---\n\n"
@@ -237,7 +239,7 @@ async def search_endpoint(request: SearchRequest):
)
except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
+ raise HTTPException(status_code=500, detail=str(e)) from e
# ---------------------------------------------------------------------------
@@ -248,9 +250,9 @@ async def search_endpoint(request: SearchRequest):
import uvicorn
print(f"""
-{'='*55}
+{"=" * 55}
Blockchain News Agent (ERC-8183 — Mount Mode)
-{'='*55}
+{"=" * 55}
Port: {PORT}
Commerce: {config.effective_commerce_address}
Router: {config.effective_router_address}
@@ -267,7 +269,7 @@ async def search_endpoint(request: SearchRequest):
GET / — Service info
POST /search — Direct news search
GET /erc8183/health — Health check
-{'='*55}
+{"=" * 55}
""")
uvicorn.run(app, host="0.0.0.0", port=PORT)
diff --git a/python/examples/agent-server/tests/test_routes_poll.py b/python/examples/agent-server/tests/test_routes_poll.py
index afc0147..a929511 100644
--- a/python/examples/agent-server/tests/test_routes_poll.py
+++ b/python/examples/agent-server/tests/test_routes_poll.py
@@ -8,9 +8,8 @@
import time
from unittest.mock import AsyncMock, MagicMock
-from fastapi.testclient import TestClient
-
from erc8183_server import create_erc8183_app
+from fastapi.testclient import TestClient
def _fake_state(job_ops):
@@ -32,9 +31,7 @@ async def get_pending_jobs():
ops.agent_address = "0x" + "aa" * 20
ops.get_pending_jobs = get_pending_jobs
ops.verify_job = AsyncMock(side_effect=verify_results)
- ops.submit_result = AsyncMock(
- return_value={"success": True, "txHash": "0x" + "de" * 32}
- )
+ ops.submit_result = AsyncMock(return_value={"success": True, "txHash": "0x" + "de" * 32})
return ops
@@ -62,7 +59,12 @@ def _valid(job_id=1):
class TestFundedPollRetry:
def test_transient_verify_failure_retries_then_succeeds(self, monkeypatch):
- transient = {"valid": False, "error": "Temporary chain/RPC error", "error_code": "chain_unavailable", "retryable": True}
+ transient = {
+ "valid": False,
+ "error": "Temporary chain/RPC error",
+ "error_code": "chain_unavailable",
+ "retryable": True,
+ }
ops = _job_ops(
verify_results=[transient, _valid()],
pending_jobs=[{"jobId": 1}],
@@ -80,7 +82,11 @@ def test_transient_verify_failure_retries_then_succeeds(self, monkeypatch):
assert ops.submit_result.await_count == 1
def test_permanent_failure_does_not_retry(self, monkeypatch):
- permanent = {"valid": False, "error": "This agent is not the provider", "error_code": "not_assigned"}
+ permanent = {
+ "valid": False,
+ "error": "This agent is not the provider",
+ "error_code": "not_assigned",
+ }
ops = _job_ops(
verify_results=lambda job_id: permanent,
pending_jobs=[{"jobId": 1}],
@@ -94,7 +100,12 @@ def test_permanent_failure_does_not_retry(self, monkeypatch):
ops.submit_result.assert_not_called()
def test_retries_are_capped(self, monkeypatch):
- transient = {"valid": False, "error": "Temporary chain/RPC error", "error_code": "chain_unavailable", "retryable": True}
+ transient = {
+ "valid": False,
+ "error": "Temporary chain/RPC error",
+ "error_code": "chain_unavailable",
+ "retryable": True,
+ }
ops = _job_ops(
verify_results=lambda job_id: transient,
pending_jobs=[{"jobId": 1}],
@@ -110,7 +121,10 @@ def test_retries_are_capped(self, monkeypatch):
class TestResponseRoute:
- """/response maps get_response's semantic error_code (chain_unavailable→503 vs not_found→404), BUG-06."""
+ """Test mapping get_response errors to HTTP statuses (BUG-06).
+
+ ``chain_unavailable`` maps to 503 and ``not_found`` maps to 404.
+ """
def _http(self, get_response_result, monkeypatch):
ops = MagicMock()
@@ -124,7 +138,12 @@ def _http(self, get_response_result, monkeypatch):
def test_unresolvable_deliverable_forwards_503(self, monkeypatch):
http = self._http(
- {"success": False, "error": "temporarily unresolvable", "error_code": "chain_unavailable", "retryable": True},
+ {
+ "success": False,
+ "error": "temporarily unresolvable",
+ "error_code": "chain_unavailable",
+ "retryable": True,
+ },
monkeypatch,
)
assert http.get("/erc8183/job/1/response").status_code == 503
@@ -137,7 +156,5 @@ def test_genuine_not_found_is_404(self, monkeypatch):
assert http.get("/erc8183/job/1/response").status_code == 404
def test_missing_error_code_defaults_to_404(self, monkeypatch):
- http = self._http(
- {"success": False, "error": "No storage configured"}, monkeypatch
- )
+ http = self._http({"success": False, "error": "No storage configured"}, monkeypatch)
assert http.get("/erc8183/job/1/response").status_code == 404
diff --git a/python/examples/agent-server/tests/test_routes_startup.py b/python/examples/agent-server/tests/test_routes_startup.py
index 0a2a61a..06f6399 100644
--- a/python/examples/agent-server/tests/test_routes_startup.py
+++ b/python/examples/agent-server/tests/test_routes_startup.py
@@ -3,9 +3,9 @@
from unittest.mock import MagicMock
import pytest
+from erc8183_server import create_erc8183_state
from bnbagent.erc8183.config import ERC8183Config
-from erc8183_server import create_erc8183_state
from bnbagent.storage.local_storage_provider import LocalStorageProvider
@@ -27,10 +27,12 @@ class TestCreateERC8183StateStartupValidation:
def test_local_storage_without_agent_url_raises(self, monkeypatch):
monkeypatch.setattr(
"erc8183_server.ERC8183JobOps.erc8183_client",
- property(lambda self: MagicMock(
- payment_token="0x" + "00" * 20,
- token_decimals=MagicMock(return_value=18),
- )),
+ property(
+ lambda self: MagicMock(
+ payment_token="0x" + "00" * 20,
+ token_decimals=MagicMock(return_value=18),
+ )
+ ),
raising=False,
)
config = _config(LocalStorageProvider(".agent-data"), agent_url=None)
diff --git a/python/examples/auto_settle.py b/python/examples/auto_settle.py
index 301708b..8d38689 100644
--- a/python/examples/auto_settle.py
+++ b/python/examples/auto_settle.py
@@ -41,8 +41,8 @@ async def auto_settle_loop(client: ERC8183Client, poll_interval: int = 15):
if job.status == JobStatus.SUBMITTED:
now = int(time.time())
-
- # We use expiredAt as the universal escape hatch, but rely on
+
+ # We use expiredAt as the universal escape hatch, but rely on
# router.settle's internal check for the dispute window.
if job.expired_at <= now:
logger.debug(f"Job {job_id} expired, waiting for claimRefund flow.")
@@ -53,11 +53,13 @@ async def auto_settle_loop(client: ERC8183Client, poll_interval: int = 15):
# client.settle() delegates to router.settle(), which pulls the verdict.
# If the dispute window hasn't passed, it will revert.
result = await asyncio.to_thread(client.settle, job_id)
- tx_hash = result.get('transactionHash')
+ tx_hash = result.get("transactionHash")
logger.info(f"Successfully settled Job {job_id}. Tx: {tx_hash}")
except Exception as e:
# Expected to fail if the dispute window is still open
- logger.debug(f"Cannot settle Job {job_id} yet (likely dispute window open): {e}")
+ logger.debug(
+ f"Cannot settle Job {job_id} yet (likely dispute window open): {e}"
+ )
else:
# Optional: log other statuses at debug level to avoid console spam
pass
@@ -82,12 +84,13 @@ async def main():
# persist=True is default, allowing the keystore to be saved for future runs
wallet = EVMWalletProvider(
- password=wallet_password,
- private_key=private_key,
+ password=wallet_password,
+ private_key=private_key,
)
client = ERC8183Client(wallet, network=network)
await auto_settle_loop(client)
+
if __name__ == "__main__":
asyncio.run(main())
diff --git a/python/examples/client/_helpers.py b/python/examples/client/_helpers.py
index 07eca19..28eef0a 100644
--- a/python/examples/client/_helpers.py
+++ b/python/examples/client/_helpers.py
@@ -11,9 +11,9 @@
from dotenv import dotenv_values
import bnbagent
+from bnbagent.config import resolve_network
from bnbagent.erc8183 import ERC8183Client
from bnbagent.wallets import TWAK_CHAIN_FOR_NETWORK, EVMWalletProvider, TWAKProvider
-from bnbagent.config import resolve_network
ROOT = Path(__file__).resolve().parent
@@ -40,8 +40,8 @@ def _require_env(name: str) -> str:
@dataclass(frozen=True)
class Settings:
network: str
- wallet_kind: str # "evm" (default) | "twak" — switches the CLIENT wallet only
- client_pk: str | None # required for evm; unused when wallet_kind=twak
+ wallet_kind: str # "evm" (default) | "twak" — switches the CLIENT wallet only
+ client_pk: str | None # required for evm; unused when wallet_kind=twak
provider_address: str
provider_pk: str | None
voter_pk: str | None
@@ -84,7 +84,7 @@ def _demo_network(network: str):
# Prefer the NodeReal RPC from voter/.env — it has a higher block-range
# limit (5 000 blocks per get_logs) vs the public default endpoint.
voter_env = dotenv_values(ROOT.parent / "voter" / ".env")
- rpc_url = voter_env.get("RPC_URL")
+ rpc_url = voter_env.get("RPC_URL")
if rpc_url:
return dataclasses.replace(resolve_network(network), rpc_url=rpc_url)
return network
diff --git a/python/examples/client/create_and_verify.py b/python/examples/client/create_and_verify.py
index 8c676c5..22a374d 100644
--- a/python/examples/client/create_and_verify.py
+++ b/python/examples/client/create_and_verify.py
@@ -27,8 +27,8 @@
from _helpers import banner, expiry_for, load_settings, make_primary_client
-POLL_INTERVAL = 5 # seconds between status polls
-POLL_TIMEOUT = 240 # allow for one full poll cycle + on-chain submission
+POLL_INTERVAL = 5 # seconds between status polls
+POLL_TIMEOUT = 240 # allow for one full poll cycle + on-chain submission
def main() -> None:
@@ -49,7 +49,7 @@ def main() -> None:
)
decimals = client.token_decimals()
- budget = 1 * (10 ** decimals)
+ budget = 1 * (10**decimals)
# --- 1. Create + register + fund ----------------------------------------
expired_at = expiry_for(client)
@@ -72,6 +72,7 @@ def main() -> None:
# --- 2. Wait for the agent's funded-poll loop to pick up the job --------
from bnbagent.erc8183 import JobStatus
+
print(f"\n[client] waiting for agent to submit (up to {POLL_TIMEOUT}s)...")
deadline = time.time() + POLL_TIMEOUT
job = client.get_job(job_id)
@@ -87,13 +88,15 @@ def main() -> None:
# --- 4. Verify manifest hash via IPFS -----------------------------------
import httpx
+
deliverable_url = client.get_deliverable_url(job_id)
print(f" deliverableUrl: {deliverable_url}")
if deliverable_url and deliverable_url.startswith("ipfs://"):
- cid = deliverable_url[len("ipfs://"):]
+ cid = deliverable_url[len("ipfs://") :]
gateway_url = f"https://gateway.pinata.cloud/ipfs/{cid}"
print(f"\n[client] fetching manifest from IPFS: {gateway_url}")
from bnbagent.erc8183.schema import DeliverableManifest
+
try:
fetch = httpx.get(gateway_url, timeout=15)
fetch.raise_for_status()
@@ -114,7 +117,7 @@ def main() -> None:
client.dispute(job_id)
print(f"[client] dispute({job_id}) OK")
print(f"\n job {job_id} is now DISPUTED")
- print(f" → voter can review and vote in examples/voter/watch.py")
+ print(" → voter can review and vote in examples/voter/watch.py")
print(f" → after quorum, anyone can call settle({job_id})")
else:
print(
diff --git a/python/examples/client/dispute_reject.py b/python/examples/client/dispute_reject.py
index 89772ca..957a885 100644
--- a/python/examples/client/dispute_reject.py
+++ b/python/examples/client/dispute_reject.py
@@ -10,7 +10,7 @@
from _helpers import banner, expiry_for, load_settings, make_client, make_primary_client
-from bnbagent.erc8183 import DeliverableManifest, JobStatus, SCHEMA_VERSION
+from bnbagent.erc8183 import SCHEMA_VERSION, DeliverableManifest, JobStatus
def main() -> None:
@@ -20,7 +20,7 @@ def main() -> None:
banner("DISPUTE REJECT — client disputes, voter rejects")
decimals = client.token_decimals()
- budget = 1 * (10 ** decimals)
+ budget = 1 * (10**decimals)
expired_at = expiry_for(client)
res = client.create_job(
@@ -49,11 +49,15 @@ def main() -> None:
"router": provider.router.address,
"policy": provider.policy.address,
},
- response={"content": f"dispute test result for job {job_id}", "content_type": "text/plain"},
+ response={
+ "content": f"dispute test result for job {job_id}",
+ "content_type": "text/plain",
+ },
)
# In production: upload manifest.to_dict() to IPFS/storage first, then pass the URL.
# deliverable_url = storage.upload(manifest.to_dict(), f"job-{job_id}.json")
- deliverable_url = "https://example.invalid/manifest.json" # placeholder — these scripts test on-chain flow only
+ # Placeholder: these scripts test on-chain flow only.
+ deliverable_url = "https://example.invalid/manifest.json"
provider.submit(job_id, manifest.manifest_hash(), {"deliverable_url": deliverable_url})
print("[provider] submit OK")
diff --git a/python/examples/client/gen_job_report.py b/python/examples/client/gen_job_report.py
index d909368..ccf0a2a 100644
--- a/python/examples/client/gen_job_report.py
+++ b/python/examples/client/gen_job_report.py
@@ -16,43 +16,50 @@
import urllib.request
from pathlib import Path
-from dotenv import load_dotenv, dotenv_values
+from dotenv import dotenv_values, load_dotenv
ROOT = Path(__file__).resolve().parent
load_dotenv(ROOT / ".env")
-from bnbagent.erc8183 import ERC8183Client, JobStatus, Verdict
-from bnbagent.erc8183.types import REASON_APPROVED, REASON_REJECTED
-from bnbagent.wallets import EVMWalletProvider
-from bnbagent.config import resolve_network
+from bnbagent.config import resolve_network # noqa: E402
+from bnbagent.erc8183 import ERC8183Client, JobStatus, Verdict # noqa: E402
+from bnbagent.erc8183.types import REASON_APPROVED, REASON_REJECTED # noqa: E402
+from bnbagent.wallets import EVMWalletProvider # noqa: E402
SCAN_TX = "https://testnet.bscscan.com/tx/0x"
EMOJI: dict[str, str] = {
- "JobCreated": "🆕",
- "BudgetSet": "💰",
- "ProviderSet": "👤",
- "JobRegistered": "📋",
- "JobFunded": "💵",
- "JobSubmitted": "📤",
- "JobInitialised": "🗂",
- "Disputed": "⚖️",
- "VoteCast": "🗳",
- "QuorumReached": "✅",
- "JobSettled": "🏁",
- "JobFinalised": "🏁",
- "JobCompleted": "✔️",
- "JobRejected": "❌",
- "JobExpired": "⏰",
- "Refunded": "↩️",
+ "JobCreated": "🆕",
+ "BudgetSet": "💰",
+ "ProviderSet": "👤",
+ "JobRegistered": "📋",
+ "JobFunded": "💵",
+ "JobSubmitted": "📤",
+ "JobInitialised": "🗂",
+ "Disputed": "⚖️",
+ "VoteCast": "🗳",
+ "QuorumReached": "✅",
+ "JobSettled": "🏁",
+ "JobFinalised": "🏁",
+ "JobCompleted": "✔️",
+ "JobRejected": "❌",
+ "JobExpired": "⏰",
+ "Refunded": "↩️",
"PaymentReleased": "💸",
}
EVENT_MAP_KEYS = {
"commerce": [
- "JobCreated", "BudgetSet", "ProviderSet", "JobFunded",
- "JobSubmitted", "JobCompleted", "JobRejected", "JobExpired",
- "Refunded", "PaymentReleased",
+ "JobCreated",
+ "BudgetSet",
+ "ProviderSet",
+ "JobFunded",
+ "JobSubmitted",
+ "JobCompleted",
+ "JobRejected",
+ "JobExpired",
+ "Refunded",
+ "PaymentReleased",
],
"router": ["JobRegistered", "JobSettled", "JobFinalised"],
"policy": ["Disputed", "JobInitialised", "VoteCast", "QuorumReached"],
@@ -61,9 +68,9 @@
def _make_client() -> ERC8183Client:
voter_env = dotenv_values(ROOT.parent / "voter" / ".env")
- rpc_url = voter_env.get("RPC_URL")
- network = os.environ.get("NETWORK", "bsc-testnet")
- wallet = EVMWalletProvider(
+ rpc_url = voter_env.get("RPC_URL")
+ network = os.environ.get("NETWORK", "bsc-testnet")
+ wallet = EVMWalletProvider(
password="demo",
private_key=os.environ["PRIVATE_KEY"],
persist=False,
@@ -75,26 +82,28 @@ def _make_client() -> ERC8183Client:
def _collect_events(client: ERC8183Client, job_id: int) -> list[dict]:
- w3 = client.commerce.w3
+ w3 = client.commerce.w3
latest = w3.eth.block_number
submit_block = client._resolve_submit_block(job_id)
if submit_block is not None:
from_block = max(0, submit_block - 5_000)
- to_block = min(latest, submit_block + 5_000)
+ to_block = min(latest, submit_block + 5_000)
else:
from_block = max(0, latest - 1_000)
- to_block = latest
+ to_block = latest
contract_map = {
"Commerce": client.commerce.contract,
- "Router": client.router.contract,
- "Policy": client.policy.contract,
+ "Router": client.router.contract,
+ "Policy": client.policy.contract,
}
events: list[dict] = []
for (contract_key, contract_label), contract in zip(
- zip(EVENT_MAP_KEYS.keys(), contract_map.keys()), contract_map.values()
+ zip(EVENT_MAP_KEYS.keys(), contract_map.keys(), strict=True),
+ contract_map.values(),
+ strict=True,
):
for ev_name in EVENT_MAP_KEYS[contract_key]:
try:
@@ -104,13 +113,15 @@ def _collect_events(client: ERC8183Client, job_id: int) -> list[dict]:
argument_filters={"jobId": job_id},
)
for log in logs:
- events.append({
- "block": log["blockNumber"],
- "tx": log["transactionHash"].hex(),
- "contract": contract_label,
- "name": ev_name,
- "args": dict(log["args"]),
- })
+ events.append(
+ {
+ "block": log["blockNumber"],
+ "tx": log["transactionHash"].hex(),
+ "contract": contract_label,
+ "name": ev_name,
+ "args": dict(log["args"]),
+ }
+ )
except Exception as exc:
print(f" [warn] {contract_label}.{ev_name}: {exc}", file=sys.stderr)
@@ -120,17 +131,22 @@ def _collect_events(client: ERC8183Client, job_id: int) -> list[dict]:
_block_ts_cache: dict[int, int] = {}
+
def _block_ts(w3, blk: int) -> int:
if blk not in _block_ts_cache:
_block_ts_cache[blk] = w3.eth.get_block(blk)["timestamp"]
return _block_ts_cache[blk]
+
def _utc(ts: int) -> str:
- return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
+ return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime(
+ "%Y-%m-%d %H:%M:%S UTC"
+ )
_receipt_cache: dict[str, str] = {}
+
def _caller(w3, tx_hash: str) -> str:
if tx_hash not in _receipt_cache:
try:
@@ -148,14 +164,14 @@ def _resolve_deliverable(client: ERC8183Client, job_id: int) -> tuple[str | None
gateway = os.environ.get("STORAGE_GATEWAY_URL", "https://gateway.pinata.cloud/ipfs/")
if url.startswith("ipfs://"):
- fetch_url = gateway.rstrip("/") + "/" + url[len("ipfs://"):]
+ fetch_url = gateway.rstrip("/") + "/" + url[len("ipfs://") :]
elif url.startswith(("http://", "https://")):
fetch_url = url
else:
return url, None
try:
- raw = urllib.request.urlopen(fetch_url, timeout=15).read()
+ raw = urllib.request.urlopen(fetch_url, timeout=15).read()
text = raw.decode("utf-8", errors="replace")
if len(text) > 2048:
text = text[:2048] + "\n... (truncated — see URL above for full content)"
@@ -169,11 +185,35 @@ def _compute_fund_flow(events: list[dict], commerce_addr: str) -> list[dict]:
for e in events:
a = e["args"]
if e["name"] == "JobFunded":
- flows.append({"dir": "Escrowed", "from": a["client"], "to": commerce_addr, "amount": a["amount"], "event": "JobFunded"})
+ flows.append(
+ {
+ "dir": "Escrowed",
+ "from": a["client"],
+ "to": commerce_addr,
+ "amount": a["amount"],
+ "event": "JobFunded",
+ }
+ )
elif e["name"] == "Refunded":
- flows.append({"dir": "Refunded", "from": commerce_addr, "to": a["client"], "amount": a["amount"], "event": "Refunded"})
+ flows.append(
+ {
+ "dir": "Refunded",
+ "from": commerce_addr,
+ "to": a["client"],
+ "amount": a["amount"],
+ "event": "Refunded",
+ }
+ )
elif e["name"] == "PaymentReleased":
- flows.append({"dir": "Released", "from": commerce_addr, "to": a["provider"], "amount": a["amount"], "event": "PaymentReleased"})
+ flows.append(
+ {
+ "dir": "Released",
+ "from": commerce_addr,
+ "to": a["provider"],
+ "amount": a["amount"],
+ "event": "PaymentReleased",
+ }
+ )
return flows
@@ -215,15 +255,15 @@ def _render(
response_body: str | None,
fund_flows: list[dict],
) -> str:
- w3 = client.commerce.w3
+ w3 = client.commerce.w3
decimals = client.token_decimals()
- symbol = client.token_symbol()
+ symbol = client.token_symbol()
chain_id = client.network.chain_id
- now = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
+ now = datetime.datetime.now(tz=datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
- rv = client.policy.reject_votes(job_id)
+ rv = client.policy.reject_votes(job_id)
quorum = client.policy.vote_quorum()
- disp = client.policy.disputed(job_id)
+ disp = client.policy.disputed(job_id)
try:
verdict, _ = client.policy.check(job_id)
verdict_str = verdict.name
@@ -231,21 +271,22 @@ def _render(
verdict_str = "N/A"
exp_utc = _utc(job.expired_at)
- now_ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())
+ now_ts = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())
if now_ts < job.expired_at:
exp_rel = f"expires in {(job.expired_at - now_ts) // 60} min"
else:
exp_rel = f"expired {(now_ts - job.expired_at) // 60} min ago"
L: list[str] = []
- def l(s: str = "") -> None:
+
+ def l(s: str = "") -> None: # noqa: E743 - concise local Markdown line builder
L.append(s)
l(f"# Job {job_id} — On-chain Timeline Report")
l()
l(f"> Generated: {now} ")
l(f"> Network: BSC Testnet (chain_id={chain_id}) ")
- l(f"> Explorer: https://testnet.bscscan.com")
+ l("> Explorer: https://testnet.bscscan.com")
l()
l("---")
l()
@@ -276,7 +317,7 @@ def l(s: str = "") -> None:
l(f"| rejectVotes | `{rv} / {quorum}` |")
l(f"| verdict | `{verdict_str}` |")
l(f"| disputed | `{disp}` |")
- if job.deliverable and job.deliverable != b'\x00' * 32:
+ if job.deliverable and job.deliverable != b"\x00" * 32:
l(f"| deliverable hash (on-chain) | `0x{job.deliverable.hex()}` |")
l()
l("---")
@@ -285,11 +326,11 @@ def l(s: str = "") -> None:
l("## Timeline")
l()
for ev in events:
- blk = ev["block"]
- tx = ev["tx"]
- utc = _utc(_block_ts(w3, blk))
- emoji = EMOJI.get(ev["name"], "📌")
- c_from = _caller(w3, tx)
+ blk = ev["block"]
+ tx = ev["tx"]
+ utc = _utc(_block_ts(w3, blk))
+ emoji = EMOJI.get(ev["name"], "📌")
+ c_from = _caller(w3, tx)
l(f"### {emoji} `{ev['name']}` — {ev['contract']} — block {blk} — {utc}")
l()
@@ -313,11 +354,11 @@ def l(s: str = "") -> None:
if deliverable_url:
l(f"- **deliverable_url**: `{deliverable_url}`")
if deliverable_url.startswith("ipfs://"):
- cid = deliverable_url[len("ipfs://"):]
+ cid = deliverable_url[len("ipfs://") :]
gateway = os.environ.get("STORAGE_GATEWAY_URL", "https://gateway.pinata.cloud/ipfs/")
l(f"- **CID**: `{cid}`")
l(f"- **Gateway URL**: {gateway.rstrip('/')}/{cid}")
- if job.deliverable and job.deliverable != b'\x00' * 32:
+ if job.deliverable and job.deliverable != b"\x00" * 32:
l(f"- **Manifest hash (on-chain)**: `0x{job.deliverable.hex()}`")
l()
if response_body:
@@ -331,7 +372,10 @@ def l(s: str = "") -> None:
else:
l("> Response body could not be fetched.")
else:
- l("> deliverable_url not found (job may not be submitted yet, or event is outside the scan window).")
+ l(
+ "> deliverable_url not found (job may not be submitted yet, "
+ "or event is outside the scan window)."
+ )
l()
l("---")
l()
@@ -347,8 +391,10 @@ def l(s: str = "") -> None:
l()
provider_received = sum(f["amount"] for f in fund_flows if f["event"] == "PaymentReleased")
- client_refunded = sum(f["amount"] for f in fund_flows if f["event"] == "Refunded")
- client_spent = sum(f["amount"] for f in fund_flows if f["event"] == "JobFunded") - client_refunded
+ client_refunded = sum(f["amount"] for f in fund_flows if f["event"] == "Refunded")
+ client_spent = (
+ sum(f["amount"] for f in fund_flows if f["event"] == "JobFunded") - client_refunded
+ )
l(f"> **Provider received**: {provider_received / 10**decimals:.4f} {symbol} ")
l(f"> **Client net cost**: {client_spent / 10**decimals:.4f} {symbol}")
@@ -377,7 +423,7 @@ def l(s: str = "") -> None:
def main(job_id: int) -> None:
print(f"Generating job-{job_id}-report.md ...")
client = _make_client()
- job = client.get_job(job_id)
+ job = client.get_job(job_id)
print(f" job {job_id}: status={job.status.name} provider={job.provider}")
print(" Collecting on-chain events ...")
@@ -390,7 +436,7 @@ def main(job_id: int) -> None:
fund_flows = _compute_fund_flow(events, client.commerce.address)
- md = _render(client, job_id, job, events, deliverable_url, response_body, fund_flows)
+ md = _render(client, job_id, job, events, deliverable_url, response_body, fund_flows)
out = ROOT / f"job-{job_id}-report.md"
out.write_text(md, encoding="utf-8")
print(f"Done: {out}")
diff --git a/python/examples/client/happy.py b/python/examples/client/happy.py
index 52e35f4..3f7ce1d 100644
--- a/python/examples/client/happy.py
+++ b/python/examples/client/happy.py
@@ -10,7 +10,7 @@
from _helpers import banner, expiry_for, load_settings, make_client, make_primary_client
-from bnbagent.erc8183 import DeliverableManifest, JobStatus, SCHEMA_VERSION
+from bnbagent.erc8183 import SCHEMA_VERSION, DeliverableManifest, JobStatus
def main() -> None:
@@ -20,7 +20,7 @@ def main() -> None:
banner("HAPPY — create + fund + submit + settle")
decimals = client.token_decimals()
- budget = 1 * (10 ** decimals) # 1 token
+ budget = 1 * (10**decimals) # 1 token
expired_at = expiry_for(client) # disputeWindow + 10 min slack
res = client.create_job(
@@ -61,7 +61,8 @@ def main() -> None:
)
# In production: upload manifest.to_dict() to IPFS/storage first, then pass the URL.
# deliverable_url = storage.upload(manifest.to_dict(), f"job-{job_id}.json")
- deliverable_url = "https://example.invalid/manifest.json" # placeholder — these scripts test on-chain flow only
+ # Placeholder: these scripts test on-chain flow only.
+ deliverable_url = "https://example.invalid/manifest.json"
provider.submit(job_id, manifest.manifest_hash(), {"deliverable_url": deliverable_url})
print("[provider] submit OK (Funded -> Submitted)")
diff --git a/python/examples/client/stalemate_expire.py b/python/examples/client/stalemate_expire.py
index 9c8d2bb..1539a7c 100644
--- a/python/examples/client/stalemate_expire.py
+++ b/python/examples/client/stalemate_expire.py
@@ -17,7 +17,7 @@
from _helpers import banner, expiry_for, load_settings, make_client, make_primary_client
-from bnbagent.erc8183 import DeliverableManifest, JobStatus, SCHEMA_VERSION
+from bnbagent.erc8183 import SCHEMA_VERSION, DeliverableManifest, JobStatus
def main() -> None:
@@ -27,7 +27,7 @@ def main() -> None:
banner("STALEMATE — dispute without quorum, refund at expiry")
decimals = client.token_decimals()
- budget = 1 * (10 ** decimals)
+ budget = 1 * (10**decimals)
# Smallest expiry that still admits a valid submit: disputeWindow + 1 min.
expired_at = expiry_for(client, slack_minutes=1)
@@ -56,11 +56,15 @@ def main() -> None:
"router": provider.router.address,
"policy": provider.policy.address,
},
- response={"content": f"stalemate test result for job {job_id}", "content_type": "text/plain"},
+ response={
+ "content": f"stalemate test result for job {job_id}",
+ "content_type": "text/plain",
+ },
)
# In production: upload manifest.to_dict() to IPFS/storage first, then pass the URL.
# deliverable_url = storage.upload(manifest.to_dict(), f"job-{job_id}.json")
- deliverable_url = "https://example.invalid/manifest.json" # placeholder — these scripts test on-chain flow only
+ # Placeholder: these scripts test on-chain flow only.
+ deliverable_url = "https://example.invalid/manifest.json"
provider.submit(job_id, manifest.manifest_hash(), {"deliverable_url": deliverable_url})
print("[provider] submit OK")
diff --git a/python/examples/client/zero_price.py b/python/examples/client/zero_price.py
index 19cca3b..57c14d8 100644
--- a/python/examples/client/zero_price.py
+++ b/python/examples/client/zero_price.py
@@ -21,15 +21,14 @@
from _helpers import banner, expiry_for, load_settings, make_client, make_primary_client
-from bnbagent.erc8183 import DeliverableManifest, JobStatus, SCHEMA_VERSION
+from bnbagent.erc8183 import SCHEMA_VERSION, DeliverableManifest, JobStatus
def main() -> None:
s = load_settings()
if not s.provider_pk:
raise RuntimeError(
- "PROVIDER_PRIVATE_KEY is required for the zero-price flow: "
- "submit is provider-only."
+ "PROVIDER_PRIVATE_KEY is required for the zero-price flow: submit is provider-only."
)
client = make_primary_client(s) # EVM or twak, per WALLET_KIND
provider = make_client(s.provider_pk, s.network)
@@ -71,7 +70,8 @@ def main() -> None:
},
response={"content": f"zero-price result for job {job_id}", "content_type": "text/plain"},
)
- deliverable_url = "https://example.invalid/manifest.json" # placeholder — these scripts test on-chain flow only
+ # Placeholder: these scripts test on-chain flow only.
+ deliverable_url = "https://example.invalid/manifest.json"
provider.submit(job_id, manifest.manifest_hash(), {"deliverable_url": deliverable_url})
print("[provider] submit OK (Funded -> Submitted)")
diff --git a/python/examples/security/e2e.py b/python/examples/security/e2e.py
index 75dae5f..d168ea7 100644
--- a/python/examples/security/e2e.py
+++ b/python/examples/security/e2e.py
@@ -40,13 +40,10 @@
)
from bnbagent.x402 import (
X402AmountExceededError,
- X402PolicyError,
X402RecipientMismatchError,
)
-logging.basicConfig(
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s"
-)
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s")
log = logging.getLogger("security_e2e")
# ── Fixtures ───────────────────────────────────────────────────────────────
@@ -56,8 +53,12 @@
# if you need a deterministic key for a specific repro.
PK = os.environ.get("E2E_PRIVATE_KEY") or Account.create().key.hex()
U_TESTNET = get_address(BSC_TESTNET_CHAIN_ID).payment_token
-log.info("U testnet address: %s (name=%r version=%r)",
- U_TESTNET, PAYMENT_TOKEN_EIP712_NAME, PAYMENT_TOKEN_EIP712_VERSION)
+log.info(
+ "U testnet address: %s (name=%r version=%r)",
+ U_TESTNET,
+ PAYMENT_TOKEN_EIP712_NAME,
+ PAYMENT_TOKEN_EIP712_VERSION,
+)
EIP712_DOMAIN_FIELDS = [
{"name": "name", "type": "string"},
@@ -126,7 +127,9 @@ def assert_1_default_signs_u_token_and_round_trips(tmpdir: str) -> None:
# Round-trip recover
message_types = {k: v for k, v in types.items() if k != "EIP712Domain"}
signable = encode_typed_data(
- domain_data=domain, message_types=message_types, message_data=msg,
+ domain_data=domain,
+ message_types=message_types,
+ message_data=msg,
)
recovered = Account.recover_message(signable, signature=signed["signature"])
assert recovered == wallet.address, (recovered, wallet.address)
@@ -200,7 +203,10 @@ def assert_5_x402signer_rejects_overvalue(tmpdir: str) -> None:
msg = twa_message(wallet, value=2_000_000)
try:
signer.sign_payment(
- domain=domain, types=types, message=msg, expected_to=msg["to"],
+ domain=domain,
+ types=types,
+ message=msg,
+ expected_to=msg["to"],
)
except X402AmountExceededError as e:
log.info(" → X402AmountExceededError as expected: %s", e)
@@ -218,7 +224,9 @@ def assert_6_x402signer_rejects_recipient_mismatch(tmpdir: str) -> None:
msg = twa_message(wallet, to="0x" + "b" * 40)
try:
signer.sign_payment(
- domain=domain, types=types, message=msg,
+ domain=domain,
+ types=types,
+ message=msg,
expected_to="0x" + "9" * 40, # different!
)
except X402RecipientMismatchError as e:
diff --git a/python/examples/turnkey_e2e.py b/python/examples/turnkey_e2e.py
new file mode 100644
index 0000000..4f2e055
--- /dev/null
+++ b/python/examples/turnkey_e2e.py
@@ -0,0 +1,192 @@
+"""Live BSC-testnet E2E for the Python Turnkey wallet provider — 4 gated steps.
+
+⚠️ SPENDS REAL MONEY-SHAPED RESOURCES. Every successful Turnkey signature is
+BILLED against the org's quota (free tier: 25 signatures/month at 1
+request/second; pay-as-you-go $0.10/signature). A full run consumes exactly
+4 billed signatures plus a few 10⁻⁵ tBNB of gas for two self-transfers.
+Calls are strictly serial with a ≥1.1 s gap; the chain-id assertion runs
+BEFORE anything billable.
+
+⚠️ Production posture: run with a NON-ROOT API user restricted by an
+explicit ALLOW policy — root API keys bypass ALL Turnkey server-side
+policies. The SDK-side SigningPolicy still applies either way.
+
+Steps (each proves a self-built wire-format piece against the real enclave):
+ 1. EIP-191 blind digest signing recovers to the Turnkey address.
+ 2. EIP-712 signing: the self-built full-document payload (EIP712Domain
+ included) parses in the enclave and binds the REAL domain.
+ 3. A legacy (gasPrice) self-transfer: the self-built unsigned RLP is
+ accepted, signs, broadcasts over our own RPC and lands on-chain.
+ 4. An EIP-1559 self-transfer does the same for the typed encoding.
+
+Usage (env in ``python/.env`` or the shell, never committed)::
+
+ TURNKEY_E2E=1
+ TURNKEY_API_PUBLIC_KEY=... TURNKEY_API_PRIVATE_KEY=...
+ TURNKEY_ORG_ID=... TURNKEY_SIGN_WITH=0x...
+ # optional: TURNKEY_API_BASE_URL, RPC_URL
+ uv run python examples/turnkey_e2e.py
+
+Requires the turnkey extra (``pip install 'bnbagent[turnkey]'``) and a
+little tBNB on the TURNKEY_SIGN_WITH address (~0.001). Exits 0 only when
+every step passes. Deliberately NOT part of CI.
+"""
+
+from __future__ import annotations
+
+import os
+import secrets
+import sys
+import time
+
+from dotenv import load_dotenv
+from eth_account import Account
+from eth_account.messages import encode_defunct, encode_typed_data
+from web3 import Web3
+
+from bnbagent.networks.addresses import BNB_CHAIN_ADDRESSES
+from bnbagent.wallets import TurnkeyWalletProvider
+
+CHAIN_ID = 97
+DEFAULT_RPC = "https://data-seed-prebsc-1-s1.binance.org:8545"
+GAP_S = 1.1 # free tier: 1 request/second — stay under it
+SIGNATURE_BUDGET = 4
+
+_billed = 0
+_last_vendor_call = 0.0
+
+
+def vendor(label: str, fn):
+ """Serialize vendor calls (≥1.1 s apart) and count the budget."""
+ global _billed, _last_vendor_call
+ if _billed >= SIGNATURE_BUDGET:
+ raise RuntimeError(f"signature budget {SIGNATURE_BUDGET} exhausted before {label!r}")
+ wait = _last_vendor_call + GAP_S - time.monotonic()
+ if wait > 0:
+ time.sleep(wait)
+ _last_vendor_call = time.monotonic()
+ result = fn()
+ _billed += 1
+ print(f" [budget] {_billed}/{SIGNATURE_BUDGET} billed signatures")
+ return result
+
+
+def main() -> int:
+ load_dotenv()
+ if os.environ.get("TURNKEY_E2E") != "1":
+ print(
+ "TURNKEY_E2E != 1 — refusing to run (this script consumes billed "
+ "Turnkey signatures and testnet gas). Set TURNKEY_E2E=1 plus the "
+ "TURNKEY_* env vars to opt in."
+ )
+ return 0
+
+ rpc_url = os.environ.get("RPC_URL") or DEFAULT_RPC
+ w3 = Web3(Web3.HTTPProvider(rpc_url))
+
+ # ── Gate 0: chain identity, BEFORE anything billable ────────────────
+ chain_id = w3.eth.chain_id
+ if chain_id != CHAIN_ID:
+ raise RuntimeError(f"RPC {rpc_url} reports chainId={chain_id}, need {CHAIN_ID}")
+
+ wallet = TurnkeyWalletProvider.from_env(expected_chain_id=CHAIN_ID)
+ address = wallet.address
+ balance = w3.eth.get_balance(address)
+ print(
+ f"turnkey e2e: signer={address} balance={w3.from_wei(balance, 'ether')} tBNB rpc={rpc_url}"
+ )
+ if balance < 3 * 10**14:
+ raise RuntimeError(
+ "signer balance is below the ~0.0003 tBNB needed for two "
+ f"self-transfers — fund {address} first"
+ )
+
+ # ── 1. EIP-191 ───────────────────────────────────────────────────────
+ message = f"turnkey-py-e2e {int(time.time())}"
+ signed = vendor("eip191", lambda: wallet.sign_message(message))
+ recovered = Account.recover_message(
+ encode_defunct(text=message), signature=signed["signature"]
+ )
+ assert recovered == address, f"191 recovered {recovered}, want {address}"
+ print(f"✅ 1/4 EIP-191 — recovered {recovered}")
+
+ # ── 2. EIP-712 with real-domain binding ─────────────────────────────
+ payment_token = BNB_CHAIN_ADDRESSES[CHAIN_ID].payment_token
+ now = int(time.time())
+ domain = {
+ "name": "United Stables",
+ "version": "1",
+ "chainId": CHAIN_ID,
+ "verifyingContract": payment_token,
+ }
+ types = {
+ "TransferWithAuthorization": [
+ {"name": "from", "type": "address"},
+ {"name": "to", "type": "address"},
+ {"name": "value", "type": "uint256"},
+ {"name": "validAfter", "type": "uint256"},
+ {"name": "validBefore", "type": "uint256"},
+ {"name": "nonce", "type": "bytes32"},
+ ],
+ }
+ message_712 = {
+ "from": address,
+ "to": address,
+ "value": 1,
+ "validAfter": now - 10,
+ "validBefore": now + 580,
+ "nonce": "0x" + secrets.token_hex(32),
+ }
+ signed_712 = vendor("eip712", lambda: wallet.sign_typed_data(domain, types, message_712))
+ signable = encode_typed_data(domain_data=domain, message_types=types, message_data=message_712)
+ recovered_712 = Account.recover_message(signable, signature=signed_712["signature"])
+ assert recovered_712 == address, (
+ f"712 recovered {recovered_712} against the REAL domain, want {address}"
+ )
+ print(f"✅ 2/4 EIP-712 — real-domain recovery {recovered_712}")
+
+ # ── 3+4. legacy and 1559 self-transfers over our own RPC ────────────
+ gas_price = w3.eth.gas_price
+ nonce = w3.eth.get_transaction_count(address, "pending")
+ transactions = [
+ (
+ "3/4 legacy tx",
+ {
+ "chainId": CHAIN_ID,
+ "to": address,
+ "value": 1,
+ "gas": 21_000,
+ "nonce": nonce,
+ "gasPrice": gas_price,
+ },
+ ),
+ (
+ "4/4 eip-1559 tx",
+ {
+ "chainId": CHAIN_ID,
+ "to": address,
+ "value": 1,
+ "gas": 21_000,
+ "nonce": nonce + 1,
+ "maxFeePerGas": gas_price * 2,
+ "maxPriorityFeePerGas": gas_price,
+ },
+ ),
+ ]
+ for step, tx in transactions:
+ signed_tx = vendor(step, lambda tx=tx: wallet.sign_transaction(tx))
+ tx_hash = w3.eth.send_raw_transaction(signed_tx["rawTransaction"])
+ receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
+ assert receipt["status"] == 1, f"{step}: reverted {tx_hash.hex()}"
+ print(f"✅ {step} — landed 0x{tx_hash.hex().removeprefix('0x')}")
+
+ print(f"\nall 4 steps PASS — {_billed} billed signatures used")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ sys.exit(main())
+ except Exception as error: # noqa: BLE001 - top-level reporter
+ print(f"E2E FAILED: {error}", file=sys.stderr)
+ sys.exit(1)
diff --git a/python/examples/twak/quickstart.py b/python/examples/twak/quickstart.py
index b257346..24fd703 100644
--- a/python/examples/twak/quickstart.py
+++ b/python/examples/twak/quickstart.py
@@ -110,8 +110,9 @@ def create_throwaway_wallet(provider: TWAKProvider, home: Path) -> str:
to resolve it from TWAK_WALLET_PASSWORD / the keychain. The shipped
CLI, however, hard-requires ``--password`` on argv for ``wallet create``
(env resolution only covers *unlock*; gaps S-8, re-verified unchanged on
- v0.19.0) — so the SDK call currently fails. We try it first (so this script self-heals when either
- side fixes the mismatch) and fall back to driving the CLI directly.
+ v0.19.0) — so the SDK call currently fails. We try it first (so this script
+ self-heals when either side fixes the mismatch) and fall back to driving
+ the CLI directly.
The fallback puts the throwaway password on argv — acceptable for a
demo wallet in a tempdir, never for a real one.
"""
@@ -124,9 +125,14 @@ def create_throwaway_wallet(provider: TWAKProvider, home: Path) -> str:
print(" falling back to a direct CLI create — demo wallet only)")
proc = subprocess.run( # noqa: S603 - fixed arg list, no shell
[
- TWAK_BIN, "wallet", "create",
- "--password", os.environ["TWAK_WALLET_PASSWORD"],
- "--no-keychain", "--skip-password-check", "--json",
+ TWAK_BIN,
+ "wallet",
+ "create",
+ "--password",
+ os.environ["TWAK_WALLET_PASSWORD"],
+ "--no-keychain",
+ "--skip-password-check",
+ "--json",
],
capture_output=True,
text=True,
@@ -188,17 +194,11 @@ def step_b_capabilities(twak: TWAKProvider) -> None:
evm = EVMWalletProvider(
password="quickstart-demo",
private_key=Account.create().key.hex(), # ephemeral, never broadcast
- persist=False, # in-memory only, no keystore file
+ persist=False, # in-memory only, no keystore file
)
print(f"evm capabilities: {sorted(evm.capabilities())}")
- print(
- " evm-only: "
- f"{sorted(evm.capabilities() - twak.capabilities())}"
- )
- print(
- " twak-only: "
- f"{sorted(twak.capabilities() - evm.capabilities())}"
- )
+ print(f" evm-only: {sorted(evm.capabilities() - twak.capabilities())}")
+ print(f" twak-only: {sorted(twak.capabilities() - evm.capabilities())}")
print(
"Reading: twak signs nothing raw (no sign.transaction/typed_data) but\n"
"broadcasts its own fixed intent menu; EVM signs anything but needs\n"
@@ -250,9 +250,7 @@ def step_c_guard_rails(twak: TWAKProvider) -> None:
# the message bytes.
msg = "hello from the bnbagent twak quickstart"
signed = twak.sign_message(msg)
- recovered = Account.recover_message(
- encode_defunct(text=msg), signature=signed["signature"]
- )
+ recovered = Account.recover_message(encode_defunct(text=msg), signature=signed["signature"])
assert recovered.lower() == twak.address.lower(), (recovered, twak.address)
print("4. sign_message round-trip:")
print(f" messageHash = {signed['messageHash']}")
diff --git a/python/examples/voter/vote_reject.py b/python/examples/voter/vote_reject.py
index 10e1166..068fab2 100644
--- a/python/examples/voter/vote_reject.py
+++ b/python/examples/voter/vote_reject.py
@@ -17,11 +17,11 @@
import time
from pathlib import Path
-from dotenv import load_dotenv, dotenv_values
+from dotenv import dotenv_values, load_dotenv
+from bnbagent.config import resolve_network
from bnbagent.erc8183 import ERC8183Client
from bnbagent.wallets import EVMWalletProvider
-from bnbagent.config import resolve_network
ROOT = Path(__file__).resolve().parent
@@ -45,7 +45,7 @@ def main() -> int:
network = os.environ.get("NETWORK", "bsc-testnet")
rpc_url = dotenv_values(ROOT / ".env").get("RPC_URL")
- wallet = EVMWalletProvider(password="example", private_key=pk, persist=False)
+ wallet = EVMWalletProvider(password="example", private_key=pk, persist=False)
if rpc_url:
nc = dataclasses.replace(resolve_network(network), rpc_url=rpc_url)
erc8183 = ERC8183Client(wallet, network=nc)
@@ -58,7 +58,9 @@ def main() -> int:
print(f"{voter} is NOT a whitelisted voter on {erc8183.policy.address}", file=sys.stderr)
return 1
if not erc8183.policy.disputed(job_id):
- print(f"jobId={job_id} has not been disputed yet; voteReject would revert", file=sys.stderr)
+ print(
+ f"jobId={job_id} has not been disputed yet; voteReject would revert", file=sys.stderr
+ )
return 1
if erc8183.policy.has_voted(job_id, voter):
print(f"{voter} already voted on jobId={job_id}", file=sys.stderr)
@@ -80,7 +82,10 @@ def main() -> int:
time.sleep(2)
if new_total >= quorum:
- print(f"[voter] quorum reached ({new_total}/{quorum}); any settler can now call router.settle({job_id})")
+ print(
+ f"[voter] quorum reached ({new_total}/{quorum}); "
+ f"any settler can now call router.settle({job_id})"
+ )
else:
print(f"[voter] current reject votes: {new_total}/{quorum} — still below quorum")
return 0
diff --git a/python/examples/voter/watch.py b/python/examples/voter/watch.py
index feb37e1..8b63d8d 100644
--- a/python/examples/voter/watch.py
+++ b/python/examples/voter/watch.py
@@ -36,7 +36,7 @@ def fetch_manifest(deliverable_url: str, gateway_url: str) -> DeliverableManifes
"""Download and parse a DeliverableManifest from IPFS."""
try:
if deliverable_url.startswith("ipfs://"):
- cid = deliverable_url[len("ipfs://"):]
+ cid = deliverable_url[len("ipfs://") :]
url = f"{gateway_url.rstrip('/')}/{cid}"
else:
url = deliverable_url
@@ -48,11 +48,13 @@ def fetch_manifest(deliverable_url: str, gateway_url: str) -> DeliverableManifes
return None
-def handle_quorum_reached(erc8183: ERC8183Client, job_id: int, reject_votes: int, quorum: int) -> None:
+def handle_quorum_reached(
+ erc8183: ERC8183Client, job_id: int, reject_votes: int, quorum: int
+) -> None:
"""Called when VoteCast shows rejectVotes >= quorum — settle and print result."""
- print(f"\n{'='*60}")
+ print(f"\n{'=' * 60}")
print(f" QUORUM REACHED job_id={job_id} ({reject_votes}/{quorum} reject votes)")
- print(f"{'='*60}")
+ print(f"{'=' * 60}")
print(f" settling job {job_id}...")
try:
erc8183.settle(job_id)
@@ -69,9 +71,9 @@ def handle_disputed_job(
hint_block: int | None = None,
) -> None:
"""Show job details and prompt voter to reject or skip."""
- print(f"\n{'='*60}")
+ print(f"\n{'=' * 60}")
print(f" DISPUTED job_id={job_id}")
- print(f"{'='*60}")
+ print(f"{'=' * 60}")
already_voted = erc8183.policy.has_voted(job_id, voter)
if already_voted:
@@ -121,13 +123,13 @@ def handle_disputed_job(
choice = input("\n [r]eject [s]kip > ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\nstopped.")
- raise SystemExit(0)
+ raise SystemExit(0) from None
if choice == "r":
print(f" casting voteReject({job_id})...")
erc8183.vote_reject(job_id)
print(f" voteReject({job_id}) submitted ✓")
- print(f" (waiting for VoteCast event to check quorum...)")
+ print(" (waiting for VoteCast event to check quorum...)")
else:
print(f" skipped job {job_id}")
@@ -143,15 +145,16 @@ def main() -> None:
gateway = os.environ.get("STORAGE_GATEWAY_URL", "https://gateway.pinata.cloud/ipfs/")
from bnbagent.config import resolve_network
+
nc = resolve_network(network)
wallet = EVMWalletProvider(password="example", private_key=pk, persist=False)
- erc8183 = ERC8183Client(wallet, network=nc)
- voter = erc8183.address
+ erc8183 = ERC8183Client(wallet, network=nc)
+ voter = erc8183.address
quorum = erc8183.policy.vote_quorum()
- print(f"Voter watch loop")
+ print("Voter watch loop")
print(f" network : {nc.name}")
print(f" rpc : {nc.rpc_url}")
print(f" policy : {erc8183.policy.address}")
@@ -159,7 +162,7 @@ def main() -> None:
print(f" listed : {erc8183.policy.is_voter(voter)}")
print(f" quorum : {quorum}")
print(f" gateway : {gateway}")
- print(f"\nWatching for Disputed / VoteCast events (Ctrl+C to stop)...\n")
+ print("\nWatching for Disputed / VoteCast events (Ctrl+C to stop)...\n")
seen_disputed: set[int] = set()
settled: set[int] = set()
@@ -179,7 +182,9 @@ def main() -> None:
print(f"[{ts}] Disputed event — jobId={job_id}")
if job_id not in seen_disputed:
seen_disputed.add(job_id)
- handle_disputed_job(erc8183, job_id, voter, gateway, hint_block=log["blockNumber"])
+ handle_disputed_job(
+ erc8183, job_id, voter, gateway, hint_block=log["blockNumber"]
+ )
# --- VoteCast events ------------------------------------------------
vote_logs = erc8183.policy.contract.events.VoteCast().get_logs(
@@ -187,11 +192,14 @@ def main() -> None:
to_block=head,
)
for log in vote_logs:
- job_id = log["args"]["jobId"]
+ job_id = log["args"]["jobId"]
reject_votes = log["args"]["rejectVotes"]
- caster = log["args"]["voter"]
+ caster = log["args"]["voter"]
ts = time.strftime("%H:%M:%S")
- print(f"[{ts}] VoteCast — jobId={job_id} rejectVotes={reject_votes}/{quorum} by={caster}")
+ print(
+ f"[{ts}] VoteCast — jobId={job_id} "
+ f"rejectVotes={reject_votes}/{quorum} by={caster}"
+ )
if reject_votes >= quorum and job_id not in settled:
settled.add(job_id)
handle_quorum_reached(erc8183, job_id, reject_votes, quorum)
diff --git a/python/examples/x402/buyer_demo.py b/python/examples/x402/buyer_demo.py
index 4379fe6..ae30c3a 100644
--- a/python/examples/x402/buyer_demo.py
+++ b/python/examples/x402/buyer_demo.py
@@ -50,9 +50,7 @@
get_address,
)
-logging.basicConfig(
- level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s"
-)
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s | %(message)s")
log = logging.getLogger("x402_buyer_demo")
# ── Fixtures ──────────────────────────────────────────────────────────────
@@ -63,8 +61,8 @@
U_TESTNET = get_address(BSC_TESTNET_CHAIN_ID).payment_token
NETWORK_ID = f"eip155:{BSC_TESTNET_CHAIN_ID}"
-PAY_TO = "0x" + "be" * 20 # Mock beneficiary (server-controlled in real life)
-PRICE_BASE_UNITS = 100_000 # 0.1 U at 6 decimals — same shape as a real x402 listing
+PAY_TO = "0x" + "be" * 20 # Mock beneficiary (server-controlled in real life)
+PRICE_BASE_UNITS = 100_000 # 0.1 U at 6 decimals — same shape as a real x402 listing
SECRET_PAYLOAD = "secret payload"
# EIP-712 schema fields — identical to the production U-token EIP-3009 domain.
@@ -198,9 +196,7 @@ def _build_twa_message(from_addr: str, accept: dict[str, Any]) -> dict[str, Any]
}
-def _build_payment_envelope(
- accept: dict[str, Any], msg: dict[str, Any], signature: str
-) -> str:
+def _build_payment_envelope(accept: dict[str, Any], msg: dict[str, Any], signature: str) -> str:
"""Encode the X-PAYMENT envelope per x402 v2.
The envelope is base64(json) so it travels safely in an HTTP header.
@@ -251,8 +247,13 @@ def main() -> int:
return 1
log.info("step 1: GET %s → 402 (challenge received)", url)
accept = body["accepts"][0]
- log.info(" challenge: pay %s base units of %s to %s on %s",
- accept["amount"], accept["asset"], accept["payTo"], accept["network"])
+ log.info(
+ " challenge: pay %s base units of %s to %s on %s",
+ accept["amount"],
+ accept["asset"],
+ accept["payTo"],
+ accept["network"],
+ )
# 4. Construct the EIP-712 payload that satisfies the challenge.
domain = {
@@ -283,7 +284,9 @@ def main() -> int:
# ``signature`` may come back as HexBytes; normalize to a 0x-hex
# string for the JSON envelope.
raw_sig = signed["signature"]
- sig = raw_sig.hex() if hasattr(raw_sig, "hex") and not isinstance(raw_sig, str) else raw_sig
+ sig = (
+ raw_sig.hex() if hasattr(raw_sig, "hex") and not isinstance(raw_sig, str) else raw_sig
+ )
if not sig.startswith("0x"):
sig = "0x" + sig
log.info("step 2: signed TransferWithAuthorization (sig=%s…%s)", sig[:10], sig[-6:])
diff --git a/python/pyproject.toml b/python/pyproject.toml
index 7ae6f0f..360e4d1 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -48,12 +48,17 @@ Issues = "https://github.com/bnb-chain/bnbagent-sdk/issues"
ipfs = [
"httpx>=0.25.0",
]
+turnkey = [
+ "cryptography>=42.0.0",
+]
dev = [
"pytest>=7.4.0",
"pytest-mock>=3.11.0",
"pytest-asyncio>=0.23.0",
"httpx>=0.25.0",
"ruff>=0.4.0",
+ # Exercises the turnkey extra's stamper in CI.
+ "cryptography>=42.0.0",
]
examples = [
"aiosqlite>=0.19",
diff --git a/python/tests/test_erc8004_contract.py b/python/tests/test_erc8004_contract.py
index 2245142..653e02a 100644
--- a/python/tests/test_erc8004_contract.py
+++ b/python/tests/test_erc8004_contract.py
@@ -76,9 +76,14 @@ def test_raises_on_receipt_status_zero(self, caplog):
fn = MagicMock()
fn.estimate_gas.return_value = 100_000
fn.build_transaction.return_value = {
- "from": "0xDeadBeef", "to": "0x1234",
- "data": "0x", "value": 0, "gas": 100_000,
- "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97,
+ "from": "0xDeadBeef",
+ "to": "0x1234",
+ "data": "0x",
+ "value": 0,
+ "gas": 100_000,
+ "gasPrice": 3_000_000_000,
+ "nonce": 1,
+ "chainId": 97,
}
web3.eth.get_transaction_count.return_value = 1
web3.eth.gas_price = 3_000_000_000
@@ -117,9 +122,14 @@ def test_success_receipt_returns_normally(self):
fn = MagicMock()
fn.estimate_gas.return_value = 100_000
fn.build_transaction.return_value = {
- "from": "0xDeadBeef", "to": "0x1234",
- "data": "0x", "value": 0, "gas": 100_000,
- "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97,
+ "from": "0xDeadBeef",
+ "to": "0x1234",
+ "data": "0x",
+ "value": 0,
+ "gas": 100_000,
+ "gasPrice": 3_000_000_000,
+ "nonce": 1,
+ "chainId": 97,
}
web3.eth.get_transaction_count.return_value = 1
web3.eth.gas_price = 3_000_000_000
@@ -156,9 +166,14 @@ def _setup(self, web3=None):
fn = MagicMock()
fn.estimate_gas.return_value = 100_000
fn.build_transaction.return_value = {
- "from": "0xDeadBeef", "to": "0x1234",
- "data": "0x", "value": 0, "gas": 100_000,
- "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97,
+ "from": "0xDeadBeef",
+ "to": "0x1234",
+ "data": "0x",
+ "value": 0,
+ "gas": 100_000,
+ "gasPrice": 3_000_000_000,
+ "nonce": 1,
+ "chainId": 97,
}
web3.eth.get_transaction_count.return_value = 1
web3.eth.gas_price = 3_000_000_000
@@ -174,7 +189,9 @@ def _setup(self, web3=None):
ok_receipt = Mock()
ok_receipt.__getitem__ = lambda s, k: {
- "status": 1, "blockNumber": 1, "gasUsed": 1,
+ "status": 1,
+ "blockNumber": 1,
+ "gasUsed": 1,
"transactionHash": sent_hash,
}[k]
web3.eth.wait_for_transaction_receipt.return_value = ok_receipt
@@ -208,9 +225,6 @@ def slow_call(params):
web3.eth.call.side_effect = slow_call
- # Patch ThreadPoolExecutor so TimeoutError propagates correctly
- original_executor = concurrent.futures.ThreadPoolExecutor
-
class ImmediateTimeoutExecutor:
def __init__(self, **kwargs):
pass
@@ -226,7 +240,9 @@ def submit(self, fn, *args, **kwargs):
f.set_exception(concurrent.futures.TimeoutError())
return f
- with patch("bnbagent.wallets.local_executor._cf.ThreadPoolExecutor", ImmediateTimeoutExecutor):
+ with patch(
+ "bnbagent.wallets.local_executor._cf.ThreadPoolExecutor", ImmediateTimeoutExecutor
+ ):
result = ci._execute_transaction(fn, description="timeout-test")
web3.eth.send_raw_transaction.assert_called_once()
@@ -243,9 +259,14 @@ def test_register_agent_raises_on_revert(self):
register_fn = MagicMock()
register_fn.estimate_gas.return_value = 100_000
register_fn.build_transaction.return_value = {
- "from": "0xDeadBeef", "to": "0x1234",
- "data": "0x", "value": 0, "gas": 100_000,
- "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97,
+ "from": "0xDeadBeef",
+ "to": "0x1234",
+ "data": "0x",
+ "value": 0,
+ "gas": 100_000,
+ "gasPrice": 3_000_000_000,
+ "nonce": 1,
+ "chainId": 97,
}
ci.contract = MagicMock()
ci.contract.functions.register.return_value = register_fn
@@ -265,7 +286,9 @@ def test_register_agent_raises_on_revert(self):
revert_receipt = Mock()
revert_receipt.__getitem__ = lambda s, k: {
- "status": 0, "blockNumber": 999, "gasUsed": 21000,
+ "status": 0,
+ "blockNumber": 999,
+ "gasUsed": 21000,
"transactionHash": sent_hash,
}[k]
web3.eth.wait_for_transaction_receipt.return_value = revert_receipt
@@ -282,9 +305,14 @@ def _setup_broadcast(self, web3, sent_hash=b"\xcd" * 32):
fn = MagicMock()
fn.estimate_gas.return_value = 100_000
fn.build_transaction.return_value = {
- "from": "0xDeadBeef", "to": "0x1234",
- "data": "0x", "value": 0, "gas": 100_000,
- "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97,
+ "from": "0xDeadBeef",
+ "to": "0x1234",
+ "data": "0x",
+ "value": 0,
+ "gas": 100_000,
+ "gasPrice": 3_000_000_000,
+ "nonce": 1,
+ "chainId": 97,
}
web3.eth.get_transaction_count.return_value = 1
web3.eth.gas_price = 3_000_000_000
@@ -343,9 +371,14 @@ def _setup_for_retry(self, web3=None):
fn = MagicMock()
fn.estimate_gas.return_value = 100_000
fn.build_transaction.return_value = {
- "from": FAKE_ADDRESS, "to": FAKE_CONTRACT_ADDRESS,
- "data": "0x", "value": 0, "gas": 100_000,
- "gasPrice": 3_000_000_000, "nonce": 1, "chainId": 97,
+ "from": FAKE_ADDRESS,
+ "to": FAKE_CONTRACT_ADDRESS,
+ "data": "0x",
+ "value": 0,
+ "gas": 100_000,
+ "gasPrice": 3_000_000_000,
+ "nonce": 1,
+ "chainId": 97,
}
web3.eth.get_transaction_count.return_value = 1
web3.eth.gas_price = 3_000_000_000
@@ -360,7 +393,9 @@ def _setup_for_retry(self, web3=None):
sent_hash = b"\xab" * 32
ok_receipt = Mock()
ok_receipt.__getitem__ = lambda s, k: {
- "status": 1, "blockNumber": 1, "gasUsed": 1,
+ "status": 1,
+ "blockNumber": 1,
+ "gasUsed": 1,
"transactionHash": sent_hash,
}[k]
web3.eth.wait_for_transaction_receipt.return_value = ok_receipt
diff --git a/python/tests/test_erc8183_client.py b/python/tests/test_erc8183_client.py
index 9a1ae7d..a38b6f9 100644
--- a/python/tests/test_erc8183_client.py
+++ b/python/tests/test_erc8183_client.py
@@ -12,9 +12,9 @@
import pytest
+from bnbagent.config import NetworkConfig
from bnbagent.erc8183 import ERC8183Client
from bnbagent.erc8183.client import DEFAULT_APPROVE_FLOOR_UNITS
-from bnbagent.config import NetworkConfig
from tests.conftest import FAKE_ADDRESS
FAKE_COMMERCE = "0x" + "aa" * 20
@@ -43,10 +43,12 @@ def _mock_wallet() -> MagicMock:
@pytest.fixture
def facade(mock_web3):
"""``ERC8183Client`` wired against mock sub-clients (no real web3 traffic)."""
- with patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3), \
- patch("bnbagent.erc8183.client.CommerceClient") as mcc, \
- patch("bnbagent.erc8183.client.RouterClient") as mrc, \
- patch("bnbagent.erc8183.client.PolicyClient") as mpc:
+ with (
+ patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3),
+ patch("bnbagent.erc8183.client.CommerceClient") as mcc,
+ patch("bnbagent.erc8183.client.RouterClient") as mrc,
+ patch("bnbagent.erc8183.client.PolicyClient") as mpc,
+ ):
commerce = MagicMock()
commerce.address = FAKE_COMMERCE
router = MagicMock()
@@ -65,10 +67,12 @@ class TestInit:
def test_allows_read_only_without_wallet(self, mock_web3):
"""wallet_provider=None builds a read-only client (writes raise later
via _send_tx); address is None."""
- with patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3), \
- patch("bnbagent.erc8183.client.CommerceClient"), \
- patch("bnbagent.erc8183.client.RouterClient"), \
- patch("bnbagent.erc8183.client.PolicyClient"):
+ with (
+ patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3),
+ patch("bnbagent.erc8183.client.CommerceClient"),
+ patch("bnbagent.erc8183.client.RouterClient"),
+ patch("bnbagent.erc8183.client.PolicyClient"),
+ ):
client = ERC8183Client(None, network=_fake_network())
assert client.address is None
assert client._wallet_provider is None
@@ -99,13 +103,13 @@ def test_chain_id_mismatch_raises(self, mock_web3):
def test_accepts_network_string(self, mock_web3):
"""String preset is resolved via ``resolve_network`` under the hood."""
fake_net = _fake_network()
- with patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3), \
- patch(
- "bnbagent.erc8183.client.resolve_network", return_value=fake_net
- ) as resolve, \
- patch("bnbagent.erc8183.client.CommerceClient") as mcc, \
- patch("bnbagent.erc8183.client.RouterClient") as mrc, \
- patch("bnbagent.erc8183.client.PolicyClient") as mpc:
+ with (
+ patch("bnbagent.erc8183.client.create_web3", return_value=mock_web3),
+ patch("bnbagent.erc8183.client.resolve_network", return_value=fake_net) as resolve,
+ patch("bnbagent.erc8183.client.CommerceClient") as mcc,
+ patch("bnbagent.erc8183.client.RouterClient") as mrc,
+ patch("bnbagent.erc8183.client.PolicyClient") as mpc,
+ ):
mcc.return_value.address = FAKE_COMMERCE
mrc.return_value.address = FAKE_ROUTER
mpc.return_value.address = FAKE_POLICY
@@ -133,7 +137,9 @@ def test_defaults_to_router_as_evaluator_and_hook(self, facade):
def test_allows_overriding_hook(self, facade):
facade.commerce.create_job.return_value = {"jobId": 1}
custom_hook = "0x" + "11" * 20
- facade.create_job(expired_at=123, description="d", hook=custom_hook, skip_expiry_check=True)
+ facade.create_job(
+ expired_at=123, description="d", hook=custom_hook, skip_expiry_check=True
+ )
_, kwargs = facade.commerce.create_job.call_args
assert kwargs["evaluator"] == FAKE_ROUTER
assert kwargs["hook"] == custom_hook
@@ -147,6 +153,7 @@ def test_rejects_expired_at_within_dispute_window(self, facade):
Regression for https://github.com/bnb-chain/bnbagent-sdk/issues/41.
"""
import time
+
facade.policy.dispute_window.return_value = 7 * 86400
too_close = int(time.time()) + 86400 # 24h, well inside 7d window
with pytest.raises(ValueError, match="dispute_window"):
@@ -155,6 +162,7 @@ def test_rejects_expired_at_within_dispute_window(self, facade):
def test_accepts_expired_at_beyond_dispute_window(self, facade):
import time
+
facade.policy.dispute_window.return_value = 7 * 86400
far_enough = int(time.time()) + 8 * 86400 + 60 # 8d + 1min
facade.commerce.create_job.return_value = {"jobId": 99}
@@ -163,6 +171,7 @@ def test_accepts_expired_at_beyond_dispute_window(self, facade):
def test_skip_expiry_check_bypasses_validation(self, facade):
import time
+
facade.policy.dispute_window.return_value = 7 * 86400
facade.commerce.create_job.return_value = {"jobId": 99}
facade.create_job(
@@ -209,9 +218,7 @@ def test_skips_approve_when_allowance_sufficient(self, facade):
def test_approves_default_floor_when_amount_below_floor(self, facade):
erc20 = self._prime(facade, current_allowance=0, decimals=6)
facade.fund(job_id=1, amount=1 * 10**6)
- erc20.approve.assert_called_once_with(
- FAKE_COMMERCE, DEFAULT_APPROVE_FLOOR_UNITS * 10**6
- )
+ erc20.approve.assert_called_once_with(FAKE_COMMERCE, DEFAULT_APPROVE_FLOOR_UNITS * 10**6)
def test_approves_exact_amount_when_above_default_floor(self, facade):
erc20 = self._prime(facade, current_allowance=0, decimals=6)
@@ -331,16 +338,16 @@ def test_rate_limit_raises_typed_error(self):
from bnbagent.exceptions import RpcRangeLimitError
policy = self._policy()
- policy.contract.events.JobInitialised.return_value.get_logs.side_effect = (
- Exception("{'code': -32005, 'message': 'limit exceeded'}")
+ policy.contract.events.JobInitialised.return_value.get_logs.side_effect = Exception(
+ "{'code': -32005, 'message': 'limit exceeded'}"
)
with pytest.raises(RpcRangeLimitError):
policy.get_deliverable_url(1)
def test_other_query_error_still_returns_none(self):
policy = self._policy()
- policy.contract.events.JobInitialised.return_value.get_logs.side_effect = (
- Exception("some other rpc problem")
+ policy.contract.events.JobInitialised.return_value.get_logs.side_effect = Exception(
+ "some other rpc problem"
)
assert policy.get_deliverable_url(1) is None
diff --git a/python/tests/test_erc8183_config.py b/python/tests/test_erc8183_config.py
index 1faef16..d71b5b0 100644
--- a/python/tests/test_erc8183_config.py
+++ b/python/tests/test_erc8183_config.py
@@ -4,13 +4,65 @@
import pytest
-from bnbagent.erc8183.config import ERC8183Config
from bnbagent.config import NetworkConfig
+from bnbagent.erc8183.config import ERC8183Config
VALID_PK = "0x" + "cd" * 32
VALID_PASSWORD = "test-password"
+class TestWalletKindTurnkey:
+ """WALLET_KIND=turnkey dispatches to TurnkeyWalletProvider.from_env
+ pinned to the config network's chain id (TS ERC8183Config parity)."""
+
+ SIGN_WITH = "0x" + "7a" * 20
+
+ def _set_env(self, monkeypatch):
+ monkeypatch.setenv("TURNKEY_API_PUBLIC_KEY", "02" + "ab" * 32)
+ monkeypatch.setenv("TURNKEY_API_PRIVATE_KEY", "cd" * 32)
+ monkeypatch.setenv("TURNKEY_ORG_ID", "org-config")
+ monkeypatch.setenv("TURNKEY_SIGN_WITH", self.SIGN_WITH)
+
+ def test_dispatches_pinned_to_network_chain_id(self, monkeypatch):
+ from bnbagent.wallets.turnkey import TurnkeyWalletProvider
+
+ self._set_env(monkeypatch)
+ config = ERC8183Config(wallet_kind="turnkey")
+ assert isinstance(config.wallet_provider, TurnkeyWalletProvider)
+ assert config.wallet_provider.expected_chain_id == 97
+ assert config.wallet_provider.address.lower() == self.SIGN_WITH
+
+ def test_missing_env_names_every_var(self, monkeypatch):
+ for key in (
+ "TURNKEY_API_PUBLIC_KEY",
+ "TURNKEY_API_PRIVATE_KEY",
+ "TURNKEY_ORG_ID",
+ "TURNKEY_SIGN_WITH",
+ ):
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TURNKEY_API_PUBLIC_KEY", "02" + "ab" * 32)
+ with pytest.raises(
+ ValueError, match="TURNKEY_API_PRIVATE_KEY, TURNKEY_ORG_ID, TURNKEY_SIGN_WITH"
+ ):
+ ERC8183Config(wallet_kind="turnkey")
+
+ def test_wallet_address_anchor_drift_fails_closed(self, monkeypatch):
+ from bnbagent.wallets.errors import WalletIdentityMismatch
+
+ self._set_env(monkeypatch)
+ with pytest.raises(WalletIdentityMismatch):
+ ERC8183Config(wallet_kind="turnkey", wallet_address="0x" + "9b" * 20)
+
+ def test_matching_anchor_and_no_password_required(self, monkeypatch):
+ self._set_env(monkeypatch)
+ monkeypatch.delenv("WALLET_PASSWORD", raising=False)
+ config = ERC8183Config(
+ wallet_kind="turnkey", wallet_address=self.SIGN_WITH.upper().replace("0X", "0x")
+ )
+ assert config.wallet_provider is not None
+ assert config.wallet_provider.kind == "turnkey"
+
+
class TestInit:
def test_valid_config_with_wallet_password(self):
config = ERC8183Config(private_key=VALID_PK, wallet_password=VALID_PASSWORD)
@@ -96,17 +148,13 @@ def test_private_key_cleared_after_wrap(self):
assert config.private_key == ""
assert VALID_PK not in repr(config)
- def test_warns_when_private_key_env_persists_after_wrap(
- self, monkeypatch, caplog
- ):
+ def test_warns_when_private_key_env_persists_after_wrap(self, monkeypatch, caplog):
import logging
monkeypatch.setenv("PRIVATE_KEY", VALID_PK)
with caplog.at_level(logging.WARNING, logger="bnbagent.core.config"):
ERC8183Config(private_key=VALID_PK, wallet_password=VALID_PASSWORD)
- assert any(
- "PRIVATE_KEY is still set" in r.message for r in caplog.records
- )
+ assert any("PRIVATE_KEY is still set" in r.message for r in caplog.records)
def test_no_env_warning_when_private_key_unset(self, monkeypatch, caplog):
import logging
@@ -114,9 +162,7 @@ def test_no_env_warning_when_private_key_unset(self, monkeypatch, caplog):
monkeypatch.delenv("PRIVATE_KEY", raising=False)
with caplog.at_level(logging.WARNING, logger="bnbagent.core.config"):
ERC8183Config(private_key=VALID_PK, wallet_password=VALID_PASSWORD)
- assert not any(
- "PRIVATE_KEY is still set" in r.message for r in caplog.records
- )
+ assert not any("PRIVATE_KEY is still set" in r.message for r in caplog.records)
def test_repr_with_wallet_provider(self):
mock_wallet = MagicMock()
diff --git a/python/tests/test_erc8183_job_ops.py b/python/tests/test_erc8183_job_ops.py
index 4dc8991..9a0e0b0 100644
--- a/python/tests/test_erc8183_job_ops.py
+++ b/python/tests/test_erc8183_job_ops.py
@@ -340,9 +340,7 @@ class TestErrorSanitization:
async def test_get_job_does_not_leak_rpc_url(self):
ops = _make_ops()
client = _inject_client(ops)
- client.get_job.side_effect = Exception(
- f"429 Too Many Requests for url: {self.SECRET}"
- )
+ client.get_job.side_effect = Exception(f"429 Too Many Requests for url: {self.SECRET}")
result = await ops.get_job(1)
assert result["success"] is False
assert "SECRET_KEY" not in result["error"]
@@ -352,9 +350,7 @@ async def test_get_job_does_not_leak_rpc_url(self):
async def test_verify_job_does_not_leak_rpc_url(self):
ops = _make_ops()
client = _inject_client(ops)
- client.get_job.side_effect = Exception(
- f"Max retries exceeded with url: {self.SECRET}"
- )
+ client.get_job.side_effect = Exception(f"Max retries exceeded with url: {self.SECRET}")
result = await ops.verify_job(1)
assert result["valid"] is False
assert "SECRET_KEY" not in result["error"]
@@ -381,9 +377,7 @@ async def test_startup_scan_filters_to_funded_owned(self):
mine_funded = replace(_job(status=JobStatus.FUNDED, provider=ME), id=1)
other_funded = replace(_job(status=JobStatus.FUNDED, provider=OTHER), id=2)
mine_completed = replace(_job(status=JobStatus.COMPLETED, provider=ME), id=3)
- client.commerce.get_jobs_batch.return_value = [
- mine_funded, other_funded, mine_completed
- ]
+ client.commerce.get_jobs_batch.return_value = [mine_funded, other_funded, mine_completed]
result = await ops.get_pending_jobs()
assert result["success"]
@@ -457,7 +451,9 @@ async def test_returns_only_submitted_for_provider(self):
other_submitted = replace(_job(status=JobStatus.SUBMITTED, provider=OTHER), id=2)
mine_funded = replace(_job(status=JobStatus.FUNDED, provider=ME), id=3)
client.commerce.get_jobs_batch.return_value = [
- mine_submitted, other_submitted, mine_funded
+ mine_submitted,
+ other_submitted,
+ mine_funded,
]
result = await ops.get_submitted_jobs()
@@ -490,11 +486,11 @@ def test_decodes_submitted_at_from_index_9(self):
"0x" + "33" * 20,
"desc",
1000,
- 2000, # expiredAt (index 6)
+ 2000, # expiredAt (index 6)
JobStatus.SUBMITTED.value, # status (index 7)
- "0x" + "44" * 20, # hook (index 8)
- 1500, # submittedAt (index 9)
- b"\x00" * 32, # deliverable (index 10)
+ "0x" + "44" * 20, # hook (index 8)
+ 1500, # submittedAt (index 9)
+ b"\x00" * 32, # deliverable (index 10)
)
job = _decode_job(raw)
assert job.submitted_at == 1500
@@ -648,8 +644,7 @@ def test_non_transient_url_is_redacted_not_replaced(self):
from bnbagent.erc8183.job_ops import _exc_error_fields
exc = RuntimeError(
- "Cannot publish: ERC8183_AGENT_URL is not set "
- "(e.g. http://localhost:8003/erc8183)"
+ "Cannot publish: ERC8183_AGENT_URL is not set (e.g. http://localhost:8003/erc8183)"
)
fields = _exc_error_fields(exc)
assert "ERC8183_AGENT_URL" in fields["error"]
@@ -703,7 +698,12 @@ async def test_never_submitted_job_is_genuine_404(self):
@pytest.mark.asyncio
async def test_unknown_status_is_chain_unavailable(self):
ops = self._ops(
- {"success": False, "error": "Temporary chain/RPC error", "error_code": "chain_unavailable", "retryable": True}
+ {
+ "success": False,
+ "error": "Temporary chain/RPC error",
+ "error_code": "chain_unavailable",
+ "retryable": True,
+ }
)
result = await ops.get_response(1)
assert result["error_code"] == "chain_unavailable"
diff --git a/python/tests/test_erc8183_policy.py b/python/tests/test_erc8183_policy.py
index 674f234..d02ae97 100644
--- a/python/tests/test_erc8183_policy.py
+++ b/python/tests/test_erc8183_policy.py
@@ -12,7 +12,9 @@ def policy_client(mock_web3):
wallet = MagicMock()
wallet.address = "0x" + "aa" * 20
# Mocking the _load_abi to avoid file I/O errors if ABI isn't found
- with patch("bnbagent.erc8183.policy._load_abi", return_value=[{"type": "function", "name": "dispute"}]):
+ with patch(
+ "bnbagent.erc8183.policy._load_abi", return_value=[{"type": "function", "name": "dispute"}]
+ ):
client = PolicyClient(mock_web3, "0x" + "11" * 20, wallet)
client._send_tx = MagicMock()
client._execute_intent = MagicMock()
@@ -102,7 +104,9 @@ def test_set_quorum(self, policy_client):
class TestGetDeliverableUrl:
def test_rpc_range_error(self, policy_client):
policy_client.contract.events.JobInitialised = MagicMock()
- policy_client.contract.events.JobInitialised().get_logs.side_effect = Exception("limit exceeded")
+ policy_client.contract.events.JobInitialised().get_logs.side_effect = Exception(
+ "limit exceeded"
+ )
with pytest.raises(RpcRangeLimitError):
policy_client.get_deliverable_url(1, hint_block=100)
diff --git a/python/tests/test_erc8183_router.py b/python/tests/test_erc8183_router.py
index 2464d76..d5bf467 100644
--- a/python/tests/test_erc8183_router.py
+++ b/python/tests/test_erc8183_router.py
@@ -1,7 +1,6 @@
from unittest.mock import MagicMock, patch
import pytest
-from web3 import Web3
from bnbagent.erc8183.router import RouterClient
from bnbagent.erc8183.types import JobStatus, Verdict
@@ -11,7 +10,9 @@
def router_client(mock_web3):
wallet = MagicMock()
wallet.address = "0x" + "aa" * 20
- with patch("bnbagent.erc8183.router._load_abi", return_value=[{"type": "function", "name": "settle"}]):
+ with patch(
+ "bnbagent.erc8183.router._load_abi", return_value=[{"type": "function", "name": "settle"}]
+ ):
client = RouterClient(mock_web3, "0x" + "11" * 20, wallet)
client._execute_intent = MagicMock()
client._call_with_retry = MagicMock()
@@ -54,7 +55,11 @@ def test_paused(self, router_client):
def test_get_job_registered_events(self, router_client):
router_client.contract.events.JobRegistered = MagicMock()
router_client.contract.events.JobRegistered().get_logs.return_value = [
- {"args": {"jobId": 1, "policy": "0xcc", "client": "0xaa"}, "blockNumber": 100, "transactionHash": b"hash"}
+ {
+ "args": {"jobId": 1, "policy": "0xcc", "client": "0xaa"},
+ "blockNumber": 100,
+ "transactionHash": b"hash",
+ }
]
logs = router_client.get_job_registered_events(0, client="0x" + "aa" * 20)
assert len(logs) == 1
@@ -63,7 +68,11 @@ def test_get_job_registered_events(self, router_client):
def test_get_job_settled_events(self, router_client):
router_client.contract.events.JobSettled = MagicMock()
router_client.contract.events.JobSettled().get_logs.return_value = [
- {"args": {"jobId": 1, "verdict": 1, "reason": b"reason"}, "blockNumber": 100, "transactionHash": b"hash"}
+ {
+ "args": {"jobId": 1, "verdict": 1, "reason": b"reason"},
+ "blockNumber": 100,
+ "transactionHash": b"hash",
+ }
]
logs = router_client.get_job_settled_events(0, verdict=Verdict.APPROVE)
assert len(logs) == 1
diff --git a/python/tests/test_erc8183_schema.py b/python/tests/test_erc8183_schema.py
index 7d0beb5..76b71b1 100644
--- a/python/tests/test_erc8183_schema.py
+++ b/python/tests/test_erc8183_schema.py
@@ -71,7 +71,10 @@ def test_manifest_hash_differs_for_different_job_id(self):
d1 = _manifest_dict()
d2 = _manifest_dict()
d2["job_id"] = 999
- assert DeliverableManifest.from_dict(d1).manifest_hash() != DeliverableManifest.from_dict(d2).manifest_hash()
+ assert (
+ DeliverableManifest.from_dict(d1).manifest_hash()
+ != DeliverableManifest.from_dict(d2).manifest_hash()
+ )
def test_verify_returns_true_for_matching_hash(self):
m = DeliverableManifest.from_dict(_manifest_dict())
diff --git a/python/tests/test_ipfs_storage.py b/python/tests/test_ipfs_storage.py
index 151a3ab..9f43b1b 100644
--- a/python/tests/test_ipfs_storage.py
+++ b/python/tests/test_ipfs_storage.py
@@ -34,7 +34,9 @@ async def test_upload_posts_to_pinata(self):
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
- with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client):
+ with patch(
+ "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client
+ ):
url = await provider.upload({"test": "data"})
assert url == f"ipfs://{VALID_CID}"
@@ -54,7 +56,9 @@ async def test_upload_returns_ipfs_url(self):
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
- with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client):
+ with patch(
+ "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client
+ ):
url = await provider.upload({"data": 1})
assert url.startswith("ipfs://")
@@ -71,7 +75,9 @@ async def test_upload_with_filename(self):
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
- with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client):
+ with patch(
+ "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client
+ ):
_url = await provider.upload({"data": 1}, filename="job-5.json")
call_kwargs = mock_client.post.call_args
@@ -90,7 +96,9 @@ async def test_upload_missing_cid_raises(self):
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
- with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client):
+ with patch(
+ "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client
+ ):
with pytest.raises(StorageError, match="Unexpected pinning response"):
await provider.upload({"data": 1})
@@ -106,7 +114,9 @@ async def test_download_from_gateway(self):
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
- with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client):
+ with patch(
+ "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client
+ ):
result = await provider.download(f"ipfs://{VALID_CID}")
assert result["downloaded"] is True
@@ -125,7 +135,9 @@ async def test_exists_true(self):
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
- with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client):
+ with patch(
+ "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client
+ ):
assert await provider.exists(f"ipfs://{VALID_CID}") is True
@pytest.mark.asyncio
@@ -139,7 +151,9 @@ async def test_exists_false(self):
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
- with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client):
+ with patch(
+ "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client
+ ):
assert await provider.exists(f"ipfs://{VALID_CID_2}") is False
def test_get_gateway_url(self):
@@ -160,7 +174,9 @@ async def test_upload_uses_cid_key(self):
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
- with patch("bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client):
+ with patch(
+ "bnbagent.storage.ipfs_storage_provider.httpx.AsyncClient", return_value=mock_client
+ ):
url = await provider.upload({"test": 1})
assert url == f"ipfs://{VALID_CID_3}"
diff --git a/python/tests/test_local_storage.py b/python/tests/test_local_storage.py
index 05cf4eb..c104e5e 100644
--- a/python/tests/test_local_storage.py
+++ b/python/tests/test_local_storage.py
@@ -48,7 +48,7 @@ async def test_upload_without_filename_uses_hash(self, tmp_path):
@pytest.mark.asyncio
async def test_file_permissions(self, tmp_path):
provider = LocalStorageProvider(str(tmp_path / "data"))
- url = await provider.upload({"key": "val"}, "test.json")
+ await provider.upload({"key": "val"}, "test.json")
filepath = tmp_path / "data" / "test.json"
mode = os.stat(filepath).st_mode
assert mode & stat.S_IRUSR
@@ -56,6 +56,7 @@ async def test_file_permissions(self, tmp_path):
def test_upload_sync(self, tmp_path):
from bnbagent.storage import upload_sync
+
provider = LocalStorageProvider(str(tmp_path / "data"))
url = upload_sync(provider, {"sync": True}, "sync-test.json")
assert url.startswith("file://")
@@ -136,6 +137,7 @@ async def test_upload_path_traversal_via_symlink(self, tmp_path):
def test_upload_sync_path_traversal_blocked(self, tmp_path):
from bnbagent.storage import upload_sync
+
provider = LocalStorageProvider(str(tmp_path / "data"))
with pytest.raises(StorageError, match="Path traversal blocked"):
upload_sync(provider, {"k": "v"}, "../escape.json")
diff --git a/python/tests/test_multicall.py b/python/tests/test_multicall.py
index 7c97998..89b6825 100644
--- a/python/tests/test_multicall.py
+++ b/python/tests/test_multicall.py
@@ -1,6 +1,6 @@
"""Tests for Multicall3 batch read utility."""
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock
import pytest
from eth_abi import encode as abi_encode
@@ -206,7 +206,7 @@ def test_decode_roundtrip(self):
decoded = abi_decode(output_types, fake_return)
# decoded is ((42, addr, addr, addr, "test job", 100, ...),) — unwrap tuple
job = decoded[0]
- assert job[0] == 42 # id
+ assert job[0] == 42 # id
assert job[4] == "test job" # description
- assert job[5] == 100 # budget
- assert job[7] == 1 # status
+ assert job[5] == 100 # budget
+ assert job[7] == 1 # status
diff --git a/python/tests/test_negotiation.py b/python/tests/test_negotiation.py
index dfc41e7..89095d8 100644
--- a/python/tests/test_negotiation.py
+++ b/python/tests/test_negotiation.py
@@ -329,7 +329,9 @@ def test_compact_json(self):
def test_sanitizes_brackets_in_task(self):
result = _make_accepted_result(task="[REQUEST] tricky task [VERIFY]")
desc = build_job_description(result)
- assert "[" not in desc or "negotiation_hash" in desc # only hex values may have no brackets
+ assert (
+ "[" not in desc or "negotiation_hash" in desc
+ ) # only hex values may have no brackets
parsed = json.loads(desc)
assert "[" not in parsed["task"]
@@ -530,6 +532,7 @@ def test_no_sig_without_wallet(self):
def test_negotiation_hash_is_keccak256_of_content(self):
from web3 import Web3
+
from bnbagent.erc8183.negotiation import _build_description_content
mock_wallet = MagicMock()
@@ -552,7 +555,9 @@ def test_negotiation_hash_is_keccak256_of_content(self):
canonical = json.dumps(content, sort_keys=True, separators=(",", ":"))
expected_hash = "0x" + Web3.keccak(text=canonical).hex().lstrip("0x")
# Compare (both should have 0x prefix and 64 hex chars)
- assert result.negotiation_hash == expected_hash or result.negotiation_hash.lstrip("0x") == expected_hash.lstrip("0x")
+ assert result.negotiation_hash == expected_hash or result.negotiation_hash.lstrip(
+ "0x"
+ ) == expected_hash.lstrip("0x")
def test_invalid_format_rejection(self):
handler = self._make_handler()
@@ -564,10 +569,12 @@ def test_rejects_when_description_too_long(self):
"""A task that would overflow the on-chain description cap is rejected
at negotiation time with TASK_TOO_LONG, before any quote is signed."""
handler = self._make_handler()
- result = handler.negotiate({
- "task_description": "x" * 10_000,
- "terms": {"deliverables": "summary", "quality_standards": "accurate"},
- })
+ result = handler.negotiate(
+ {
+ "task_description": "x" * 10_000,
+ "terms": {"deliverables": "summary", "quality_standards": "accurate"},
+ }
+ )
assert result.accepted is False
assert result.response.get("reason_code") == ReasonCode.TASK_TOO_LONG
@@ -635,12 +642,14 @@ def test_content_includes_chain_id_when_set(self):
assert content["chain_id"] == 56
# And the negotiation_hash must be derived from the chain-bound content.
from web3 import Web3
+
canonical = json.dumps(content, sort_keys=True, separators=(",", ":"))
expected = "0x" + Web3.keccak(text=canonical).hex().lstrip("0x")
assert result.negotiation_hash.lstrip("0x") == expected.lstrip("0x")
def test_content_includes_verifying_contract_when_set(self):
from web3 import Web3
+
from bnbagent.erc8183.negotiation import _build_description_content
commerce_addr = Web3.to_checksum_address("0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de")
@@ -654,7 +663,9 @@ def test_content_includes_verifying_contract_when_set(self):
result = handler.negotiate(self._request())
content = _build_description_content(
- result.to_dict(), chain_id=97, verifying_contract=commerce_addr,
+ result.to_dict(),
+ chain_id=97,
+ verifying_contract=commerce_addr,
)
assert content["verifying_contract"] == commerce_addr # checksummed
# Hash binds the contract too.
@@ -679,12 +690,22 @@ def test_different_chain_id_produces_different_signature(self):
mock_wallet = MagicMock()
mock_wallet.sign_message.return_value = {"signature": b"\xab" * 65}
- h_testnet = self._make_handler(
- wallet_provider=mock_wallet, chain_id=97,
- ).negotiate(self._request()).negotiation_hash
- h_mainnet = self._make_handler(
- wallet_provider=mock_wallet, chain_id=56,
- ).negotiate(self._request()).negotiation_hash
+ h_testnet = (
+ self._make_handler(
+ wallet_provider=mock_wallet,
+ chain_id=97,
+ )
+ .negotiate(self._request())
+ .negotiation_hash
+ )
+ h_mainnet = (
+ self._make_handler(
+ wallet_provider=mock_wallet,
+ chain_id=56,
+ )
+ .negotiate(self._request())
+ .negotiation_hash
+ )
assert h_testnet != h_mainnet
@@ -721,18 +742,21 @@ def _make_handler(self, **kwargs):
def test_build_job_description_includes_chain_id_when_present(self):
from web3 import Web3
- commerce_addr = Web3.to_checksum_address(
- "0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de"
- )
+
+ commerce_addr = Web3.to_checksum_address("0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de")
mock_wallet = MagicMock()
mock_wallet.sign_message.return_value = {"signature": b"\xab" * 65}
handler = self._make_handler(
- wallet_provider=mock_wallet, chain_id=56, verifying_contract=commerce_addr,
+ wallet_provider=mock_wallet,
+ chain_id=56,
+ verifying_contract=commerce_addr,
+ )
+ result = handler.negotiate(
+ {
+ "task_description": "Get news",
+ "terms": {"deliverables": "summary", "quality_standards": "accurate"},
+ }
)
- result = handler.negotiate({
- "task_description": "Get news",
- "terms": {"deliverables": "summary", "quality_standards": "accurate"},
- })
description_json = build_job_description(result.to_dict())
parsed = json.loads(description_json)
@@ -744,18 +768,21 @@ def test_signature_roundtrip_with_chain_binding(self):
compute by stripping negotiation_hash/provider_sig from the on-chain
JSON and re-running keccak. Without this, provider_sig is useless."""
from web3 import Web3
- commerce_addr = Web3.to_checksum_address(
- "0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de"
- )
+
+ commerce_addr = Web3.to_checksum_address("0xa206c0517b6371c6638cd9e4a42cc9f02a33b0de")
mock_wallet = MagicMock()
mock_wallet.sign_message.return_value = {"signature": b"\xab" * 65}
handler = self._make_handler(
- wallet_provider=mock_wallet, chain_id=97, verifying_contract=commerce_addr,
+ wallet_provider=mock_wallet,
+ chain_id=97,
+ verifying_contract=commerce_addr,
+ )
+ result = handler.negotiate(
+ {
+ "task_description": "Get news",
+ "terms": {"deliverables": "summary", "quality_standards": "accurate"},
+ }
)
- result = handler.negotiate({
- "task_description": "Get news",
- "terms": {"deliverables": "summary", "quality_standards": "accurate"},
- })
# Simulate downstream verifier:
description_json = build_job_description(result.to_dict())
@@ -789,10 +816,12 @@ def test_signing_failure_is_logged(self, caplog):
handler = self._make_handler(wallet_provider=mock_wallet, chain_id=97)
with caplog.at_level("WARNING"):
- result = handler.negotiate({
- "task_description": "Get news",
- "terms": {"deliverables": "summary", "quality_standards": "accurate"},
- })
+ result = handler.negotiate(
+ {
+ "task_description": "Get news",
+ "terms": {"deliverables": "summary", "quality_standards": "accurate"},
+ }
+ )
# Quote still returned but without sig.
assert result.accepted is True
diff --git a/python/tests/test_networks_addresses.py b/python/tests/test_networks_addresses.py
index 6e74496..494fada 100644
--- a/python/tests/test_networks_addresses.py
+++ b/python/tests/test_networks_addresses.py
@@ -9,7 +9,6 @@
BNB_CHAIN_ADDRESSES,
BSC_MAINNET_CHAIN_ID,
BSC_TESTNET_CHAIN_ID,
- DeployedAddresses,
PAYMENT_TOKEN_EIP712_NAME,
PAYMENT_TOKEN_EIP712_VERSION,
get_address,
@@ -36,9 +35,13 @@ def test_all_addresses_are_checksummed():
"""Every address on every chain must be EIP-55 checksum-encoded."""
for chain_id, deploy in BNB_CHAIN_ADDRESSES.items():
for field_name in (
- "payment_token", "treasury",
- "commerce_proxy", "commerce_impl",
- "router_proxy", "router_impl", "policy",
+ "payment_token",
+ "treasury",
+ "commerce_proxy",
+ "commerce_impl",
+ "router_proxy",
+ "router_impl",
+ "policy",
):
addr = getattr(deploy, field_name)
assert Web3.is_checksum_address(addr), (
diff --git a/python/tests/test_signing_policy.py b/python/tests/test_signing_policy.py
index 235d45b..541f4e4 100644
--- a/python/tests/test_signing_policy.py
+++ b/python/tests/test_signing_policy.py
@@ -223,9 +223,7 @@ def test_rejects_missing_validity_fields_when_required():
def _twa_fields_with(name, key, new_value):
- return [
- {**f, key: new_value} if f["name"] == name else f for f in TWA_FIELDS
- ]
+ return [{**f, key: new_value} if f["name"] == name else f for f in TWA_FIELDS]
def test_rejects_value_declared_as_int256():
@@ -296,8 +294,10 @@ def test_canonical_shape_still_accepted():
def test_receive_with_authorization_shares_the_canonical_shape():
p = SigningPolicy.strict_default()
domain = {
- "name": "United Stables", "version": "1",
- "chainId": BSC_MAINNET_CHAIN_ID, "verifyingContract": U_MAINNET,
+ "name": "United Stables",
+ "version": "1",
+ "chainId": BSC_MAINNET_CHAIN_ID,
+ "verifyingContract": U_MAINNET,
}
types = {
"EIP712Domain": EIP712DOMAIN_FIELDS,
@@ -312,8 +312,10 @@ def test_unknown_primary_type_is_not_shape_pinned():
"""
p = SigningPolicy.permissive(allow_in_production=True)
domain = {
- "name": "Custom", "version": "1",
- "chainId": BSC_MAINNET_CHAIN_ID, "verifyingContract": U_MAINNET,
+ "name": "Custom",
+ "version": "1",
+ "chainId": BSC_MAINNET_CHAIN_ID,
+ "verifyingContract": U_MAINNET,
}
types = {
"EIP712Domain": EIP712DOMAIN_FIELDS,
@@ -350,8 +352,10 @@ def test_eip712domain_shape_is_not_pinned():
{"name": "salt", "type": "bytes32"},
]
domain = {
- "name": "United Stables", "version": "1",
- "chainId": BSC_MAINNET_CHAIN_ID, "verifyingContract": U_MAINNET,
+ "name": "United Stables",
+ "version": "1",
+ "chainId": BSC_MAINNET_CHAIN_ID,
+ "verifyingContract": U_MAINNET,
}
types = {
"EIP712Domain": odd_domain_fields,
@@ -430,7 +434,10 @@ def test_extend_overrides_scalars():
def test_permissive_passes_unknown_domain_and_unknown_type():
p = SigningPolicy.permissive()
domain = {"chainId": 999, "verifyingContract": "0x" + "f" * 40}
- types = {"EIP712Domain": EIP712DOMAIN_FIELDS, "SomethingExotic": [{"name": "x", "type": "uint256"}]}
+ types = {
+ "EIP712Domain": EIP712DOMAIN_FIELDS,
+ "SomethingExotic": [{"name": "x", "type": "uint256"}],
+ }
msg = {"x": 1}
# Must not raise
pt = check(p, domain, types, msg, now=NOW)
@@ -463,9 +470,10 @@ def test_policy_violation_carries_structured_diagnostics():
def test_infer_primary_type_returns_non_domain():
- assert infer_primary_type(
- {"EIP712Domain": [], "TransferWithAuthorization": []}
- ) == "TransferWithAuthorization"
+ assert (
+ infer_primary_type({"EIP712Domain": [], "TransferWithAuthorization": []})
+ == "TransferWithAuthorization"
+ )
def test_infer_primary_type_rejects_empty():
@@ -482,15 +490,11 @@ def test_infer_primary_type_rejects_multiple():
def test_eip3009_types_set_contents():
- assert EIP3009_TYPES == frozenset(
- {"TransferWithAuthorization", "ReceiveWithAuthorization"}
- )
+ assert EIP3009_TYPES == frozenset({"TransferWithAuthorization", "ReceiveWithAuthorization"})
def test_permit_unbounded_types_contents():
- assert PERMIT_UNBOUNDED_TYPES == frozenset(
- {"Permit", "PermitSingle", "PermitBatch"}
- )
+ assert PERMIT_UNBOUNDED_TYPES == frozenset({"Permit", "PermitSingle", "PermitBatch"})
# ── Serialization ────────────────────────────────────────────────────────
@@ -562,8 +566,9 @@ def test_str_handles_empty_policy_cleanly():
# ── permissive() env guard ──────────────────────────────────────────────
-@pytest.mark.parametrize("env_value", ["prod", "production", "live", "mainnet-prod",
- "PROD", " Production ", "LIVE"])
+@pytest.mark.parametrize(
+ "env_value", ["prod", "production", "live", "mainnet-prod", "PROD", " Production ", "LIVE"]
+)
def test_permissive_refuses_in_production(monkeypatch, env_value):
monkeypatch.setenv("ENV", env_value)
with pytest.raises(RuntimeError, match="indicates production"):
diff --git a/python/tests/test_storage_from_env.py b/python/tests/test_storage_from_env.py
index aca74fc..0f767bf 100644
--- a/python/tests/test_storage_from_env.py
+++ b/python/tests/test_storage_from_env.py
@@ -2,8 +2,8 @@
import pytest
-from bnbagent.storage.local_storage_provider import LocalStorageProvider
from bnbagent.storage.ipfs_storage_provider import IPFSStorageProvider
+from bnbagent.storage.local_storage_provider import LocalStorageProvider
class TestLocalProviderFromEnv:
diff --git a/python/tests/test_turnkey_client.py b/python/tests/test_turnkey_client.py
new file mode 100644
index 0000000..12b845f
--- /dev/null
+++ b/python/tests/test_turnkey_client.py
@@ -0,0 +1,158 @@
+"""Unit tests for the stamped Turnkey HTTP activity client."""
+
+from __future__ import annotations
+
+import json
+from unittest.mock import MagicMock, patch
+
+import pytest
+import requests
+
+from bnbagent.wallets.turnkey.client import TurnkeyApiError, TurnkeyClient
+
+
+def _activity(status: str, **extra) -> dict:
+ return {"id": "activity-1", "status": status, **extra}
+
+
+def _client() -> tuple[TurnkeyClient, MagicMock, MagicMock]:
+ session = MagicMock(spec=requests.Session)
+ stamper = MagicMock()
+ with patch("bnbagent.wallets.turnkey.client.ApiKeyStamper", return_value=stamper):
+ client = TurnkeyClient(
+ api_base_url="https://api.turnkey.example/",
+ api_public_key="public",
+ api_private_key="private",
+ organization_id="org-1",
+ session=session,
+ timeout=2.0,
+ )
+ return client, session, stamper
+
+
+class TestSubmit:
+ def test_returns_immediately_when_submission_is_completed(self):
+ client, _, _ = _client()
+ completed = _activity("ACTIVITY_STATUS_COMPLETED", result={"ok": True})
+ client._post = MagicMock(return_value=completed)
+
+ with patch("bnbagent.wallets.turnkey.client.time.sleep") as sleep:
+ assert client._submit("/submit", {}) is completed
+
+ client._post.assert_called_once_with("/submit", {})
+ sleep.assert_not_called()
+
+ def test_checks_completion_returned_by_last_poll(self):
+ client, _, _ = _client()
+ created = _activity("ACTIVITY_STATUS_CREATED")
+ completed = _activity("ACTIVITY_STATUS_COMPLETED", result={"ok": True})
+ client._post = MagicMock(side_effect=[created, *([created] * 9), completed])
+
+ with patch("bnbagent.wallets.turnkey.client.time.sleep") as sleep:
+ assert client._submit("/submit", {}) is completed
+
+ assert client._post.call_count == 11
+ assert sleep.call_count == 10
+
+ def test_checks_failure_returned_by_last_poll(self):
+ client, _, _ = _client()
+ created = _activity("ACTIVITY_STATUS_CREATED")
+ failed = _activity("ACTIVITY_STATUS_FAILED", failure="policy rejected")
+ client._post = MagicMock(side_effect=[created, *([created] * 9), failed])
+
+ with (
+ patch("bnbagent.wallets.turnkey.client.time.sleep"),
+ pytest.raises(TurnkeyApiError, match="ended in ACTIVITY_STATUS_FAILED") as excinfo,
+ ):
+ client._submit("/submit", {})
+
+ assert excinfo.value.activity_status == "ACTIVITY_STATUS_FAILED"
+ assert client._post.call_count == 11
+
+ def test_raises_after_poll_timeout(self):
+ client, _, _ = _client()
+ created = _activity("ACTIVITY_STATUS_CREATED")
+ client._post = MagicMock(return_value=created)
+
+ with (
+ patch("bnbagent.wallets.turnkey.client.time.sleep") as sleep,
+ pytest.raises(TurnkeyApiError, match="still pending after 5s") as excinfo,
+ ):
+ client._submit("/submit", {})
+
+ assert excinfo.value.activity_status == "ACTIVITY_STATUS_CREATED"
+ assert client._post.call_count == 11
+ assert sleep.call_count == 10
+
+
+class TestPost:
+ def test_stamp_matches_exact_wire_body(self):
+ client, session, stamper = _client()
+ body = {"organizationId": "org-1", "parameters": {"probe": True}}
+ payload = json.dumps(body, separators=(",", ":"))
+ stamper.stamp.return_value = ("X-Stamp", "stamp-value")
+ response = session.post.return_value
+ response.status_code = 200
+ response.json.return_value = {"activity": _activity("ACTIVITY_STATUS_COMPLETED")}
+
+ client._post("/public/v1/submit/probe", body)
+
+ stamper.stamp.assert_called_once_with(payload)
+ session.post.assert_called_once_with(
+ "https://api.turnkey.example/public/v1/submit/probe",
+ data=payload.encode("utf-8"),
+ headers={"Content-Type": "application/json", "X-Stamp": "stamp-value"},
+ timeout=2.0,
+ )
+
+ @pytest.mark.parametrize(
+ ("response_body", "expected"),
+ [
+ ({"message": "invalid stamp"}, "invalid stamp"),
+ (["first error", "second error"], "first error"),
+ ],
+ )
+ def test_maps_http_error_with_json_body(self, response_body, expected):
+ client, session, stamper = _client()
+ stamper.stamp.return_value = ("X-Stamp", "stamp-value")
+ response = session.post.return_value
+ response.status_code = 400
+ response.json.return_value = response_body
+
+ with pytest.raises(TurnkeyApiError, match=expected) as excinfo:
+ client._post("/public/v1/submit/probe", {})
+
+ assert excinfo.value.status_code == 400
+
+ def test_maps_http_error_with_text_body(self):
+ client, session, stamper = _client()
+ stamper.stamp.return_value = ("X-Stamp", "stamp-value")
+ response = session.post.return_value
+ response.status_code = 500
+ response.json.side_effect = ValueError
+ response.text = "upstream unavailable"
+
+ with pytest.raises(TurnkeyApiError, match="upstream unavailable") as excinfo:
+ client._post("/public/v1/submit/probe", {})
+
+ assert excinfo.value.status_code == 500
+
+ def test_rejects_non_json_success_body(self):
+ client, session, stamper = _client()
+ stamper.stamp.return_value = ("X-Stamp", "stamp-value")
+ response = session.post.return_value
+ response.status_code = 200
+ response.json.side_effect = ValueError
+
+ with pytest.raises(TurnkeyApiError, match="returned non-JSON body"):
+ client._post("/public/v1/submit/probe", {})
+
+ def test_rejects_success_body_without_activity_envelope(self):
+ client, session, stamper = _client()
+ stamper.stamp.return_value = ("X-Stamp", "stamp-value")
+ response = session.post.return_value
+ response.status_code = 200
+ response.json.return_value = {"notActivity": {}}
+
+ with pytest.raises(TurnkeyApiError, match="has no activity envelope"):
+ client._post("/public/v1/submit/probe", {})
diff --git a/python/tests/test_turnkey_provider.py b/python/tests/test_turnkey_provider.py
new file mode 100644
index 0000000..4163178
--- /dev/null
+++ b/python/tests/test_turnkey_provider.py
@@ -0,0 +1,467 @@
+"""``TurnkeyWalletProvider`` (Python) — provider-specific suite.
+
+Mirrors ``typescript/tests/turnkeyProvider.test.ts``: construction
+validation, ``from_env``, policy-before-billing ordering, the EIP-712
+payload contract (domain included by construction — the ``@turnkey/viem``
+0.14.x stripping trap, immunized here and pinned by the fake enclave),
+legacy/1559 transaction round-trips, vendor error mapping, and the
+stamper's wire format. The fake enclave signs with ``eth_account``, so
+every signature is real and recoverable.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import sys
+import time
+
+import pytest
+from eth_account import Account
+from eth_account.messages import defunct_hash_message, encode_defunct, encode_typed_data
+from eth_utils import keccak, to_checksum_address
+
+from bnbagent.signing import PolicyViolation, SigningPolicy
+from bnbagent.wallets.capabilities import (
+ CALLS_ARBITRARY,
+ PAYMASTER_SPONSOR,
+ SIGN_MESSAGE,
+ SIGN_TRANSACTION,
+ SIGN_TYPED_DATA,
+)
+from bnbagent.wallets.turnkey import (
+ TURNKEY_API_BASE_URL_DEFAULT,
+ TurnkeyApiError,
+ TurnkeyWalletProvider,
+)
+from bnbagent.wallets.turnkey.stamper import ApiKeyStamper
+
+from .turnkey_fake import FakeTurnkeyClient
+
+# The Turnkey-hosted key the fake "enclave" signs with; SIGN_WITH is its
+# address, so signatures recover to the provider address.
+TEST_PK = "0x" + "c3" * 32
+SIGN_WITH = Account.from_key(TEST_PK).address
+
+
+def _p256_fixture() -> tuple[str, str]:
+ """Deterministic P-256 key pair (private hex, compressed public hex)."""
+ ec = pytest.importorskip("cryptography.hazmat.primitives.asymmetric.ec")
+ from cryptography.hazmat.primitives.serialization import (
+ Encoding,
+ PublicFormat,
+ )
+
+ private = (
+ int("d9" * 32, 16)
+ % (0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 - 1)
+ + 1
+ )
+ key = ec.derive_private_key(private, ec.SECP256R1())
+ public = key.public_key().public_bytes(Encoding.X962, PublicFormat.CompressedPoint)
+ return format(private, "064x"), public.hex()
+
+
+BASE_KWARGS = {
+ "organization_id": "org-123",
+ "sign_with": SIGN_WITH,
+ "api_public_key": "02" + "ab" * 32,
+ "api_private_key": "cd" * 32,
+}
+
+# A domain NOT in known_payment_tokens — strict_default must refuse it.
+TEST_DOMAIN = {
+ "name": "TestToken",
+ "version": "1",
+ "chainId": 97,
+ "verifyingContract": to_checksum_address("0x" + "22" * 20),
+}
+
+
+def eip3009_fixture() -> tuple[dict, dict]:
+ now = int(time.time())
+ types = {
+ "TransferWithAuthorization": [
+ {"name": "from", "type": "address"},
+ {"name": "to", "type": "address"},
+ {"name": "value", "type": "uint256"},
+ {"name": "validAfter", "type": "uint256"},
+ {"name": "validBefore", "type": "uint256"},
+ {"name": "nonce", "type": "bytes32"},
+ ],
+ }
+ message = {
+ "from": SIGN_WITH,
+ "to": to_checksum_address("0x" + "33" * 20),
+ # Big enough to exercise the decimal-string JSON spelling.
+ "value": 10**18,
+ "validAfter": now - 10,
+ "validBefore": now + 580,
+ "nonce": "0x" + "44" * 32,
+ }
+ return types, message
+
+
+def extended_policy() -> SigningPolicy:
+ return SigningPolicy.strict_default().extend(
+ domain_allowlist={(97, TEST_DOMAIN["verifyingContract"])}
+ )
+
+
+@pytest.fixture
+def fake_client() -> FakeTurnkeyClient:
+ return FakeTurnkeyClient(TEST_PK)
+
+
+def make_provider(fake_client: FakeTurnkeyClient, **overrides) -> TurnkeyWalletProvider:
+ kwargs = {**BASE_KWARGS, "client": fake_client, **overrides}
+ return TurnkeyWalletProvider(**kwargs)
+
+
+TURNKEY_ENV = {
+ "TURNKEY_API_PUBLIC_KEY": BASE_KWARGS["api_public_key"],
+ "TURNKEY_API_PRIVATE_KEY": BASE_KWARGS["api_private_key"],
+ "TURNKEY_ORG_ID": BASE_KWARGS["organization_id"],
+ "TURNKEY_SIGN_WITH": SIGN_WITH,
+}
+
+
+# ── construction ──────────────────────────────────────────────────────
+
+
+class TestConstruction:
+ def test_capability_surface(self, fake_client):
+ provider = make_provider(fake_client)
+ assert provider.capabilities() == frozenset(
+ {
+ SIGN_MESSAGE,
+ SIGN_TRANSACTION,
+ SIGN_TYPED_DATA,
+ CALLS_ARBITRARY,
+ PAYMASTER_SPONSOR,
+ }
+ )
+ assert provider.kind == "turnkey"
+
+ def test_address_is_checksummed_from_lowercase(self, fake_client):
+ provider = make_provider(fake_client, sign_with=SIGN_WITH.lower())
+ assert provider.address == SIGN_WITH
+
+ def test_describe_reports_remote_key_location_without_secrets(self, fake_client):
+ provider = make_provider(fake_client)
+ info = provider.describe()
+ assert info["address"] == SIGN_WITH
+ assert "remote:turnkey" in info["key_location"]
+ assert TURNKEY_API_BASE_URL_DEFAULT in info["key_location"]
+ assert BASE_KWARGS["api_private_key"] not in json.dumps(info)
+
+ @pytest.mark.parametrize(
+ "sign_with",
+ [
+ "3f2504e0-4f89-11d3-9a0c-0305e82c3301", # Turnkey wallet id (UUID)
+ "pk-12345", # private-key id
+ "0x" + "ab" * 19 + "a", # 39 hex chars
+ ],
+ )
+ def test_rejects_non_address_sign_with(self, fake_client, sign_with):
+ with pytest.raises(ValueError, match="Ethereum\\s+.*address|Ethereum "):
+ make_provider(fake_client, sign_with=sign_with)
+
+ @pytest.mark.parametrize(
+ "field", ["organization_id", "sign_with", "api_public_key", "api_private_key"]
+ )
+ def test_requires_non_empty_credentials(self, fake_client, field):
+ with pytest.raises(ValueError, match=f"{field!r} is required"):
+ make_provider(fake_client, **{field: ""})
+
+ def test_construction_is_offline(self, fake_client):
+ provider = make_provider(fake_client)
+ provider.describe()
+ assert fake_client.raw_payload_calls == []
+ assert fake_client.transaction_calls == []
+
+
+# ── from_env ──────────────────────────────────────────────────────────
+
+
+class TestFromEnv:
+ def test_builds_from_the_four_env_vars(self, monkeypatch):
+ for key, value in TURNKEY_ENV.items():
+ monkeypatch.setenv(key, value)
+ monkeypatch.delenv("TURNKEY_API_BASE_URL", raising=False)
+ provider = TurnkeyWalletProvider.from_env(expected_chain_id=97)
+ assert provider.address == SIGN_WITH
+ assert provider.expected_chain_id == 97
+ assert TURNKEY_API_BASE_URL_DEFAULT in provider.key_location
+
+ def test_honors_base_url_and_policy(self, monkeypatch):
+ for key, value in TURNKEY_ENV.items():
+ monkeypatch.setenv(key, value)
+ monkeypatch.setenv("TURNKEY_API_BASE_URL", "https://api.turnkey.example")
+ policy = extended_policy()
+ provider = TurnkeyWalletProvider.from_env(signing_policy=policy)
+ assert "https://api.turnkey.example" in provider.key_location
+ assert provider.signing_policy is policy
+
+ def test_names_all_missing_env_vars(self, monkeypatch):
+ for key in TURNKEY_ENV:
+ monkeypatch.delenv(key, raising=False)
+ monkeypatch.setenv("TURNKEY_API_PUBLIC_KEY", "02" + "ab" * 32)
+ with pytest.raises(
+ ValueError,
+ match=(
+ "missing required env vars: TURNKEY_API_PRIVATE_KEY, "
+ "TURNKEY_ORG_ID, TURNKEY_SIGN_WITH"
+ ),
+ ):
+ TurnkeyWalletProvider.from_env()
+
+
+# ── sign_message (EIP-191) ────────────────────────────────────────────
+
+
+class TestSignMessage:
+ def test_round_trip_recovers_to_provider_address(self, fake_client):
+ provider = make_provider(fake_client)
+ result = provider.sign_message("hello turnkey")
+ assert bytes(result["messageHash"]) == bytes(defunct_hash_message(text="hello turnkey"))
+ assert result["v"] in (27, 28)
+ recovered = Account.recover_message(
+ encode_defunct(text="hello turnkey"), signature=result["signature"]
+ )
+ assert recovered == provider.address
+ # Blind digest path: the enclave saw HEXADECIMAL + NO_OP.
+ (call,) = fake_client.raw_payload_calls
+ assert call["encoding"] == "PAYLOAD_ENCODING_HEXADECIMAL"
+ assert call["hash_function"] == "HASH_FUNCTION_NO_OP"
+
+
+# ── sign_typed_data (EIP-712) ─────────────────────────────────────────
+
+
+class TestSignTypedData:
+ def test_policy_check_runs_before_any_billable_call(self, fake_client):
+ provider = make_provider(fake_client)
+ types, message = eip3009_fixture()
+ with pytest.raises(PolicyViolation):
+ provider.sign_typed_data(TEST_DOMAIN, types, message)
+ assert fake_client.raw_payload_calls == []
+
+ def test_signs_after_policy_extension(self, fake_client):
+ provider = make_provider(fake_client, signing_policy=extended_policy())
+ types, message = eip3009_fixture()
+ result = provider.sign_typed_data(TEST_DOMAIN, types, message)
+ assert result["v"] in (27, 28)
+
+ def test_payload_carries_the_full_eip712_domain(self, fake_client):
+ # The Python provider builds the enclave payload itself, so the
+ # @turnkey/viem stripping trap cannot occur — pinned here.
+ provider = make_provider(fake_client, signing_policy=extended_policy())
+ types, message = eip3009_fixture()
+ provider.sign_typed_data(TEST_DOMAIN, types, message)
+ (call,) = fake_client.raw_payload_calls
+ assert call["encoding"] == "PAYLOAD_ENCODING_EIP712"
+ document = json.loads(call["payload"])
+ assert document["types"]["EIP712Domain"] == [
+ {"name": "name", "type": "string"},
+ {"name": "version", "type": "string"},
+ {"name": "chainId", "type": "uint256"},
+ {"name": "verifyingContract", "type": "address"},
+ ]
+ assert document["domain"]["chainId"] == 97 # small int stays a number
+ assert document["message"]["value"] == str(10**18) # big int → string
+ assert document["primaryType"] == "TransferWithAuthorization"
+
+ def test_replaces_caller_supplied_eip712_domain(self, fake_client):
+ provider = make_provider(fake_client, signing_policy=extended_policy())
+ types, message = eip3009_fixture()
+ with_bogus = {"EIP712Domain": [{"name": "name", "type": "string"}], **types}
+ first = provider.sign_typed_data(TEST_DOMAIN, with_bogus, message)
+ second = provider.sign_typed_data(TEST_DOMAIN, types, message)
+ assert bytes(first["signature"]) == bytes(second["signature"])
+ for call in fake_client.raw_payload_calls:
+ document = json.loads(call["payload"])
+ assert len(document["types"]["EIP712Domain"]) == 4
+
+ def test_binds_the_real_domain_and_reports_the_712_digest(self, fake_client):
+ provider = make_provider(fake_client, signing_policy=extended_policy())
+ types, message = eip3009_fixture()
+ result = provider.sign_typed_data(TEST_DOMAIN, types, message)
+ signable = encode_typed_data(
+ domain_data=TEST_DOMAIN,
+ message_types={k: v for k, v in types.items() if k != "EIP712Domain"},
+ message_data=message,
+ )
+ expected_digest = keccak(b"\x19" + signable.version + signable.header + signable.body)
+ assert bytes(result["messageHash"]) == expected_digest
+ recovered = Account.recover_message(signable, signature=result["signature"])
+ assert recovered == provider.address
+
+ def test_rejects_multi_struct_types_before_any_billable_call(self, fake_client):
+ provider = make_provider(fake_client, signing_policy=extended_policy())
+ types, message = eip3009_fixture()
+ multi = {**types, "Extra": [{"name": "x", "type": "uint256"}]}
+ with pytest.raises(PolicyViolation):
+ provider.sign_typed_data(TEST_DOMAIN, multi, message)
+ assert fake_client.raw_payload_calls == []
+
+
+# ── sign_transaction ──────────────────────────────────────────────────
+
+
+LEGACY_TX = {
+ "chainId": 97,
+ "to": to_checksum_address("0x" + "55" * 20),
+ "value": 1,
+ "nonce": 0,
+ "gas": 21_000,
+ "gasPrice": 10_000_000_000,
+}
+
+
+class TestSignTransaction:
+ def test_legacy_round_trip(self, fake_client):
+ provider = make_provider(fake_client)
+ signed = provider.sign_transaction(dict(LEGACY_TX))
+ raw = bytes(signed["rawTransaction"])
+ assert bytes(signed["hash"]) == keccak(raw)
+ # EIP-155 v for chainId 97: 2*97 + 35/36.
+ assert signed["v"] in (229, 230)
+ assert Account.recover_transaction(raw) == provider.address
+
+ def test_eip1559_round_trip(self, fake_client):
+ provider = make_provider(fake_client)
+ tx = {
+ "chainId": 97,
+ "to": to_checksum_address("0x" + "55" * 20),
+ "value": 1,
+ "nonce": 0,
+ "gas": 21_000,
+ "maxFeePerGas": 10_000_000_000,
+ "maxPriorityFeePerGas": 1_000_000_000,
+ }
+ signed = provider.sign_transaction(tx)
+ raw = bytes(signed["rawTransaction"])
+ assert raw[:1] == b"\x02"
+ # eth-account semantics: typed transactions report the y-parity bit.
+ assert signed["v"] in (0, 1)
+ assert Account.recover_transaction(raw) == provider.address
+
+ def test_gas_price_zero_paymaster_shape(self, fake_client):
+ # MegaFuel sponsorship signs gasPrice=0 legacy transactions
+ # (enclave-verified, probe 2026-07-27); the codec must not drop the
+ # canonical zero.
+ provider = make_provider(fake_client)
+ signed = provider.sign_transaction({**LEGACY_TX, "gasPrice": 0})
+ assert Account.recover_transaction(bytes(signed["rawTransaction"]))
+ assert signed["v"] in (229, 230)
+
+ def test_contract_creation_omits_to(self, fake_client):
+ provider = make_provider(fake_client)
+ tx = {**LEGACY_TX, "to": None, "data": "0x6001600155"}
+ signed = provider.sign_transaction(tx)
+ assert Account.recover_transaction(bytes(signed["rawTransaction"]))
+
+ def test_refuses_chain_id_mismatch_before_the_billable_call(self, fake_client):
+ provider = make_provider(fake_client, expected_chain_id=97)
+ with pytest.raises(ValueError, match="pinned to chainId=97"):
+ provider.sign_transaction({**LEGACY_TX, "chainId": 56})
+ assert fake_client.transaction_calls == []
+ assert provider.expected_chain_id == 97
+
+ def test_rejects_access_list_transactions(self, fake_client):
+ provider = make_provider(fake_client)
+ with pytest.raises(ValueError, match="accessList"):
+ provider.sign_transaction({**LEGACY_TX, "accessList": [{"address": "0x" + "11" * 20}]})
+
+
+# ── vendor error mapping ──────────────────────────────────────────────
+
+
+class TestVendorErrorMapping:
+ def test_quota_exhaustion_hint(self, fake_client):
+ provider = make_provider(fake_client)
+ fake_client.failure = TurnkeyApiError(
+ "SIGNING_QUOTA_EXCEEDED for organization", status_code=429
+ )
+ with pytest.raises(RuntimeError, match="25 billed signatures/month"):
+ provider.sign_message("x")
+
+ @pytest.mark.parametrize(
+ "message",
+ [
+ "Turnkey error 8: RATE_LIMIT_EXCEEDED",
+ "Turnkey error 8: RATE LIMIT EXCEEDED",
+ "Turnkey error 8: rate-limit exceeded",
+ "Turnkey error 8: ratelimit hit",
+ ],
+ )
+ def test_rate_limit_hint(self, fake_client, message):
+ provider = make_provider(fake_client)
+ fake_client.failure = TurnkeyApiError(message)
+ with pytest.raises(RuntimeError, match="1 request/second"):
+ provider.sign_message("x")
+
+ def test_rate_limit_status_hint(self, fake_client):
+ provider = make_provider(fake_client)
+ fake_client.failure = TurnkeyApiError("too many requests", status_code=429)
+ with pytest.raises(RuntimeError, match="1 request/second"):
+ provider.sign_message("x")
+
+ def test_policy_denied_hint(self, fake_client):
+ provider = make_provider(fake_client)
+ fake_client.failure = TurnkeyApiError(
+ "policy engine rejected the activity (POLICY_REJECTED)",
+ status_code=403,
+ )
+ with pytest.raises(RuntimeError, match="explicit ALLOW policy"):
+ provider.sign_message("x")
+
+ def test_unrecognized_errors_pass_through(self, fake_client):
+ provider = make_provider(fake_client)
+ original = TurnkeyApiError("something else entirely")
+ fake_client.failure = original
+ with pytest.raises(TurnkeyApiError) as excinfo:
+ provider.sign_message("x")
+ assert excinfo.value is original
+
+
+# ── stamper wire format ───────────────────────────────────────────────
+
+
+class TestStamper:
+ def test_stamp_shape_and_signature_verify(self):
+ private_hex, public_hex = _p256_fixture()
+ stamper = ApiKeyStamper(api_public_key=public_hex, api_private_key=private_hex)
+ header_name, header_value = stamper.stamp('{"probe":true}')
+ assert header_name == "X-Stamp"
+ assert "=" not in header_value # base64url padding stripped
+ padded = header_value + "=" * (-len(header_value) % 4)
+ stamp = json.loads(base64.urlsafe_b64decode(padded))
+ assert stamp["scheme"] == "SIGNATURE_SCHEME_TK_API_P256"
+ assert stamp["publicKey"] == public_hex
+
+ from cryptography.hazmat.primitives import hashes
+ from cryptography.hazmat.primitives.asymmetric import ec
+
+ public_key = ec.EllipticCurvePublicKey.from_encoded_point(
+ ec.SECP256R1(), bytes.fromhex(public_hex)
+ )
+ # Raises InvalidSignature on mismatch — DER over the exact bytes.
+ public_key.verify(
+ bytes.fromhex(stamp["signature"]),
+ b'{"probe":true}',
+ ec.ECDSA(hashes.SHA256()),
+ )
+
+ def test_mismatched_key_pair_fails_fast(self):
+ private_hex, _ = _p256_fixture()
+ with pytest.raises(RuntimeError, match="does not match"):
+ ApiKeyStamper(api_public_key="02" + "ab" * 32, api_private_key=private_hex)
+
+ def test_missing_cryptography_yields_install_hint(self, monkeypatch):
+ monkeypatch.setitem(sys.modules, "cryptography", None)
+ monkeypatch.setitem(sys.modules, "cryptography.hazmat", None)
+ monkeypatch.setitem(sys.modules, "cryptography.hazmat.primitives", None)
+ with pytest.raises(RuntimeError, match="bnbagent\\[turnkey\\]"):
+ ApiKeyStamper(api_public_key="02" + "ab" * 32, api_private_key="cd" * 32)
diff --git a/python/tests/test_twak_provider.py b/python/tests/test_twak_provider.py
index aee0910..f6b27d8 100644
--- a/python/tests/test_twak_provider.py
+++ b/python/tests/test_twak_provider.py
@@ -51,7 +51,11 @@
# Canonical twak --json outputs (field-verified v0.18.0 shapes).
_REGISTER_OUT = {
- "success": True, "agentId": 42, "hash": "0xreg", "owner": FAKE_ADDRESS, "chain": "bsc",
+ "success": True,
+ "agentId": 42,
+ "hash": "0xreg",
+ "owner": FAKE_ADDRESS,
+ "chain": "bsc",
}
# spec spelling ("txHash"): exercises the hash-alias chain on the happy path
_SETMETA_OUT = {"success": True, "txHash": "0xmeta", "chain": "bsc"}
@@ -130,6 +134,7 @@ def _make_twak_contract(twak):
# ── TWAKProvider is a self-broadcasting executor ──
+
def test_twak_provider_is_intent_executor():
assert isinstance(TWAKProvider(), IntentExecutor)
@@ -184,17 +189,20 @@ def test_make_executor_captures_paymaster_url_and_writes_carry_flag():
# write commands as --paymaster-url; reads (the status probe) never are.
run, calls = _intent_router(_TX_OUT)
twak = TWAKProvider()
- executor = twak.make_executor(
- ExecutionContext(web3=MagicMock(), paymaster=_paymaster())
- )
+ executor = twak.make_executor(ExecutionContext(web3=MagicMock(), paymaster=_paymaster()))
assert executor is twak
- _execute(
- twak, Intent(name=ERC8183_SETTLE, kwargs={"job_id": 137, "evidence": b""}), run
- )
+ _execute(twak, Intent(name=ERC8183_SETTLE, kwargs={"job_id": 137, "evidence": b""}), run)
assert calls[0] == _WALLET_STATUS_CMD
assert calls[1] == [
- "twak", "erc8183", "settle", "137",
- "--paymaster-url", _PAYMASTER_URL, "--chain", "bsc", "--json",
+ "twak",
+ "erc8183",
+ "settle",
+ "137",
+ "--paymaster-url",
+ _PAYMASTER_URL,
+ "--chain",
+ "bsc",
+ "--json",
]
@@ -265,6 +273,7 @@ def test_contract_paymaster_flows_to_twak_writes():
# ── erc8004.register: atomic --metadata flags (v0.18.0, no replay) ──
+
def test_register_atomic_metadata_flags_and_field_mapping():
run, calls = _router()
twak = TWAKProvider()
@@ -294,11 +303,18 @@ def test_register_atomic_metadata_flags_and_field_mapping():
# one atomic register invocation: repeatable --metadata, no set-metadata replay
assert calls[0] == _WALLET_STATUS_CMD
assert calls[1] == [
- "twak", "erc8004", "register",
- "--uri", "https://agent.example/card.json",
- "--metadata", "built_with=https://github.com/bnb-chain/bnbagent-sdk#v1",
- "--metadata", "foo=bar",
- "--chain", "bsc", "--json",
+ "twak",
+ "erc8004",
+ "register",
+ "--uri",
+ "https://agent.example/card.json",
+ "--metadata",
+ "built_with=https://github.com/bnb-chain/bnbagent-sdk#v1",
+ "--metadata",
+ "foo=bar",
+ "--chain",
+ "bsc",
+ "--json",
]
assert len(calls) == 2
@@ -331,6 +347,7 @@ def test_contract_register_end_to_end_no_web3_send():
# ── other erc8004 intents ──
+
def test_set_metadata_via_contract():
run, calls = _router()
twak = TWAKProvider()
@@ -355,6 +372,7 @@ def test_set_agent_uri_via_contract_uses_set_uri():
# ── erc8183 dispatch table: exact argv + normalised result ──
+
@pytest.mark.parametrize(
("name", "kwargs", "expected_argv"),
[
@@ -368,8 +386,12 @@ def test_set_agent_uri_via_contract_uses_set_uri():
ERC8183_SET_PROVIDER,
{"job_id": 137, "provider": _PROVIDER_ADDR, "opt_params": b"\x01\x02"},
[
- "set-provider", "137", "--provider", _PROVIDER_ADDR,
- "--opt-params", "0x0102",
+ "set-provider",
+ "137",
+ "--provider",
+ _PROVIDER_ADDR,
+ "--opt-params",
+ "0x0102",
],
id="set_provider-opt-params-passthrough-s1",
),
@@ -401,8 +423,12 @@ def test_set_agent_uri_via_contract_uses_set_uri():
ERC8183_COMPLETE,
{"job_id": 137, "reason": b"\x12" * 32, "opt_params": b"\x01"},
[
- "complete", "137", "--reason", "0x" + "12" * 32,
- "--opt-params", "0x01",
+ "complete",
+ "137",
+ "--reason",
+ "0x" + "12" * 32,
+ "--opt-params",
+ "0x01",
],
id="complete-reason-then-opt-params",
),
@@ -496,12 +522,20 @@ def test_create_job_omits_hook_for_zero_address():
"jobId": 138,
}
assert calls[1] == [
- "twak", "erc8183", "create-job",
- "--provider", _PROVIDER_ADDR,
- "--evaluator", _EVALUATOR_ADDR,
- "--expires-at", "1750000000", # twak's flag name; carries expired_at
- "--description", "index the docs",
- "--chain", "bsc", "--json",
+ "twak",
+ "erc8183",
+ "create-job",
+ "--provider",
+ _PROVIDER_ADDR,
+ "--evaluator",
+ _EVALUATOR_ADDR,
+ "--expires-at",
+ "1750000000", # twak's flag name; carries expired_at
+ "--description",
+ "index the docs",
+ "--chain",
+ "bsc",
+ "--json",
]
@@ -524,18 +558,28 @@ def test_create_job_includes_nonzero_hook():
)
assert result["jobId"] == 139
assert calls[1] == [
- "twak", "erc8183", "create-job",
- "--provider", _PROVIDER_ADDR,
- "--evaluator", _EVALUATOR_ADDR,
- "--expires-at", "1750000000",
- "--description", "index the docs",
- "--hook", _HOOK_ADDR,
- "--chain", "bsc", "--json",
+ "twak",
+ "erc8183",
+ "create-job",
+ "--provider",
+ _PROVIDER_ADDR,
+ "--evaluator",
+ _EVALUATOR_ADDR,
+ "--expires-at",
+ "1750000000",
+ "--description",
+ "index the docs",
+ "--hook",
+ _HOOK_ADDR,
+ "--chain",
+ "bsc",
+ "--json",
]
# ── erc8183.fund: --expected-budget atomic pin (gaps S-2, v0.19.0) ──
+
def _fund_intent(expected_budget, opt_params=b""):
return Intent(
name=ERC8183_FUND,
@@ -548,9 +592,7 @@ def _fund_intent(expected_budget, opt_params=b""):
def test_fund_pins_expected_budget_no_status_precheck_and_surfaces_approve_hash():
- run, calls = _intent_router(
- {"success": True, "hash": "0xfund", "approveHash": "0xappr"}
- )
+ run, calls = _intent_router({"success": True, "hash": "0xfund", "approveHash": "0xappr"})
twak = TWAKProvider()
result = _execute(twak, _fund_intent(1000), run)
assert result == {
@@ -563,8 +605,15 @@ def test_fund_pins_expected_budget_no_status_precheck_and_surfaces_approve_hash(
# old client-side `erc8183 status` pre-check is gone entirely.
assert calls[0] == _WALLET_STATUS_CMD
assert calls[1] == [
- "twak", "erc8183", "fund", "137",
- "--expected-budget", "1000", "--chain", "bsc", "--json",
+ "twak",
+ "erc8183",
+ "fund",
+ "137",
+ "--expected-budget",
+ "1000",
+ "--chain",
+ "bsc",
+ "--json",
]
assert len(calls) == 2
assert all(not (c[1] == "erc8183" and c[2] == "status") for c in calls)
@@ -575,9 +624,17 @@ def test_fund_opt_params_passthrough():
twak = TWAKProvider()
_execute(twak, _fund_intent(1000, opt_params=b"\xca\xfe"), run)
assert calls[1] == [
- "twak", "erc8183", "fund", "137",
- "--expected-budget", "1000", "--opt-params", "0xcafe",
- "--chain", "bsc", "--json",
+ "twak",
+ "erc8183",
+ "fund",
+ "137",
+ "--expected-budget",
+ "1000",
+ "--opt-params",
+ "0xcafe",
+ "--chain",
+ "bsc",
+ "--json",
]
@@ -609,6 +666,7 @@ def run(cmd, **kwargs):
# ── opt-params passthrough (REQ-1 / S-1, v0.19.0) ──
+
def test_submit_opt_params_passthrough_req1():
# v0.19.0 (REQ-1): the deliverable_url JSON the SDK facade encodes into
# optParams rides the submit tx verbatim — seller role works end-to-end.
@@ -625,10 +683,17 @@ def test_submit_opt_params_passthrough_req1():
)
assert result == {"success": True, "transactionHash": "0xfeed", "receipt": None}
assert calls[1] == [
- "twak", "erc8183", "submit", "137",
- "--deliverable", "0x" + "ab" * 32,
- "--opt-params", "0x" + opt.hex(),
- "--chain", "bsc", "--json",
+ "twak",
+ "erc8183",
+ "submit",
+ "137",
+ "--deliverable",
+ "0x" + "ab" * 32,
+ "--opt-params",
+ "0x" + opt.hex(),
+ "--chain",
+ "bsc",
+ "--json",
]
@@ -644,8 +709,17 @@ def test_set_budget_opt_params_passthrough_s1():
run,
)
assert calls[1] == [
- "twak", "erc8183", "set-budget", "137", "--amount", "1",
- "--opt-params", "0x78", "--chain", "bsc", "--json",
+ "twak",
+ "erc8183",
+ "set-budget",
+ "137",
+ "--amount",
+ "1",
+ "--opt-params",
+ "0x78",
+ "--chain",
+ "bsc",
+ "--json",
]
@@ -706,9 +780,7 @@ def test_unnamed_intent_rejected():
(ERC8183_VOTE_REJECT, NETWORKS["bsc-mainnet"].policy_contract),
],
)
-def test_custom_contract_intent_rejected_before_any_cli_call(
- name, canonical_target
-):
+def test_custom_contract_intent_rejected_before_any_cli_call(name, canonical_target):
twak = TWAKProvider()
call = types.SimpleNamespace(address="0x" + "99" * 20)
with patch("bnbagent.wallets.twak_provider.subprocess.run") as run:
@@ -739,9 +811,7 @@ def test_custom_erc8004_target_requires_matching_env_override(name):
def test_custom_erc8004_target_allowed_with_matching_env_override(monkeypatch):
custom_registry = "0x" + "99" * 20
monkeypatch.setenv("ERC8004_REGISTRY_ADDRESS", custom_registry)
- run, calls = _intent_router(
- {"success": True, "hash": "0xreg", "agentId": "1362"}
- )
+ run, calls = _intent_router({"success": True, "hash": "0xreg", "agentId": "1362"})
twak = TWAKProvider()
result = _execute(
twak,
@@ -759,9 +829,7 @@ def test_custom_erc8004_target_allowed_with_matching_env_override(monkeypatch):
def test_erc8004_env_override_cannot_redirect_canonical_intent(monkeypatch):
monkeypatch.setenv("ERC8004_REGISTRY_ADDRESS", "0x" + "99" * 20)
twak = TWAKProvider()
- call = types.SimpleNamespace(
- address=NETWORKS["bsc-mainnet"].registry_contract
- )
+ call = types.SimpleNamespace(address=NETWORKS["bsc-mainnet"].registry_contract)
with patch("bnbagent.wallets.twak_provider.subprocess.run") as run:
with pytest.raises(
UnsupportedWalletOperation,
@@ -801,6 +869,7 @@ def test_testnet_canonical_contract_target_allowed():
# ── output parse hardening (gaps REQ-3 + error-envelope variants) ──
+
@pytest.mark.parametrize("field", ["hash", "txHash", "transactionHash"])
def test_tx_hash_field_variants_extracted(field):
run, _ = _intent_router({"success": True, field: "0xabc"})
@@ -904,8 +973,14 @@ def test_sign_message_normalises_and_self_checks():
def run(cmd, **kwargs):
assert cmd == [
- "twak", "wallet", "sign-message",
- "--chain", "bsc", "--message", message, "--json",
+ "twak",
+ "wallet",
+ "sign-message",
+ "--chain",
+ "bsc",
+ "--message",
+ message,
+ "--json",
]
return _completed(cmd, {"success": True, "signature": raw_sig})
@@ -935,10 +1010,18 @@ def test_sign_message_uses_base_chain_key_on_testnet():
def run(cmd, **kwargs):
assert cmd == [
- "twak", "wallet", "sign-message",
- "--chain", "bsc", "--message", message, "--json",
+ "twak",
+ "wallet",
+ "sign-message",
+ "--chain",
+ "bsc",
+ "--message",
+ message,
+ "--json",
]
- return _completed(cmd, {"success": True, "signature": "0x" + bytes(signed.signature).hex()})
+ return _completed(
+ cmd, {"success": True, "signature": "0x" + bytes(signed.signature).hex()}
+ )
with patch("bnbagent.wallets.twak_provider.subprocess.run", side_effect=run):
result = twak.sign_message(message)
@@ -951,9 +1034,7 @@ def test_sign_message_recovery_mismatch_raises():
message = "hello twak"
acct = Account.from_key(_TEST_KEY)
tampered = bytes(
- Account.sign_message(
- encode_defunct(text=message), private_key="0x" + "cd" * 32
- ).signature
+ Account.sign_message(encode_defunct(text=message), private_key="0x" + "cd" * 32).signature
).hex()
twak = _primed_twak(acct.address)
@@ -1011,6 +1092,7 @@ def run(cmd, **kwargs):
# ── auto-create / create_wallet (EVM-parity) ──
+
def _status_router(wallet_exists: bool):
"""Route wallet status / create / address with a toggleable existence."""
state = {"exists": wallet_exists}
@@ -1222,10 +1304,19 @@ def run(cmd, **kwargs):
with patch("bnbagent.wallets.twak_provider.subprocess.run", side_effect=run):
twak.x402_quote("https://pay.example/x402", method="POST", body='{"amountUsd":0.1}')
- assert calls == [[
- "twak", "x402", "quote", "https://pay.example/x402",
- "--method", "POST", "--body", '{"amountUsd":0.1}', "--json",
- ]]
+ assert calls == [
+ [
+ "twak",
+ "x402",
+ "quote",
+ "https://pay.example/x402",
+ "--method",
+ "POST",
+ "--body",
+ '{"amountUsd":0.1}',
+ "--json",
+ ]
+ ]
def test_x402_quote_https_only_error_surfaces():
@@ -1262,8 +1353,14 @@ def test_x402_request_argv_minimal_with_wallet_probe_first():
assert calls[0] == _WALLET_STATUS_CMD
# minimal argv: --max-payment + --yes, and no --prefer-*/--method/--body
assert calls[1] == [
- "twak", "x402", "request", "https://pay.example/x402",
- "--max-payment", "1000", "--yes", "--json",
+ "twak",
+ "x402",
+ "request",
+ "https://pay.example/x402",
+ "--max-payment",
+ "1000",
+ "--yes",
+ "--json",
]
assert len(calls) == 2
# success output = the endpoint body verbatim (no receipt, gaps S-7)
@@ -1286,12 +1383,23 @@ def test_x402_request_argv_includes_prefer_flags_only_when_set():
)
assert calls[1] == [
- "twak", "x402", "request", "https://pay.example/x402",
- "--max-payment", str(10**17), "--yes",
- "--method", "POST", "--body", '{"amountUsd":0.1}',
- "--prefer-network", "eip155:56",
- "--prefer-method", "eip3009",
- "--prefer-asset", u_asset,
+ "twak",
+ "x402",
+ "request",
+ "https://pay.example/x402",
+ "--max-payment",
+ str(10**17),
+ "--yes",
+ "--method",
+ "POST",
+ "--body",
+ '{"amountUsd":0.1}',
+ "--prefer-network",
+ "eip155:56",
+ "--prefer-method",
+ "eip3009",
+ "--prefer-asset",
+ u_asset,
"--json",
]
@@ -1315,6 +1423,7 @@ def test_x402_request_min_amount_error_surfaces():
# ── live-CLI quirks surfaced by examples/twak (field-verified v0.18.0) ──
+
def test_exists_false_when_status_reports_not_configured():
# `wallet status` exits 0 even with no wallet; only the agentWallet field
# tells the truth. exists() must not be fooled by the zero exit code.
@@ -1350,9 +1459,7 @@ def test_run_trusts_success_envelope_over_nonzero_exit():
assert data["accepts"] == []
# an error envelope with a non-zero exit still raises
with patch("bnbagent.wallets.twak_provider.subprocess.run") as run:
- run.return_value = _completed(
- ["twak"], {"error": "boom", "errorCode": "X"}, returncode=1
- )
+ run.return_value = _completed(["twak"], {"error": "boom", "errorCode": "X"}, returncode=1)
with pytest.raises(RuntimeError, match="boom"):
twak.x402_quote("https://www.x402.org/protected")
@@ -1367,7 +1474,9 @@ def run(cmd, **kwargs):
return _completed(cmd, {"agentWallet": "not configured"})
if "create" in cmd:
return _completed(
- cmd, {}, returncode=1,
+ cmd,
+ {},
+ returncode=1,
stderr="error: required option '--password ' not specified",
)
raise AssertionError(f"unexpected twak command: {cmd}")
diff --git a/python/tests/test_wallet.py b/python/tests/test_wallet.py
index d8fccaf..fa4b2ab 100644
--- a/python/tests/test_wallet.py
+++ b/python/tests/test_wallet.py
@@ -2,8 +2,6 @@
Test cases for EVMWalletProvider (~/.bnbagent/wallets/ keystore)
"""
-import json
-
import pytest
from eth_account import Account
@@ -139,7 +137,9 @@ def test_sign_typed_data_eip3009_round_trip(self, wdir):
from bnbagent.signing import SigningPolicy
wallet = EVMWalletProvider(
- password=PW, private_key=PK, wallets_dir=wdir,
+ password=PW,
+ private_key=PK,
+ wallets_dir=wdir,
signing_policy=SigningPolicy.permissive(),
)
domain = {
@@ -187,12 +187,19 @@ def test_sign_typed_data_eip3009_round_trip(self, wdir):
def test_sign_typed_data_strips_domain_type_if_supplied(self, wdir):
"""Caller may pass EIP712Domain entry in types; should produce same sig."""
from bnbagent.signing import SigningPolicy
+
wallet = EVMWalletProvider(
- password=PW, private_key=PK, wallets_dir=wdir,
+ password=PW,
+ private_key=PK,
+ wallets_dir=wdir,
signing_policy=SigningPolicy.permissive(),
)
- domain = {"name": "Test", "version": "1", "chainId": 56,
- "verifyingContract": "0x" + "1" * 40}
+ domain = {
+ "name": "Test",
+ "version": "1",
+ "chainId": 56,
+ "verifyingContract": "0x" + "1" * 40,
+ }
types_with_domain = {
"EIP712Domain": [
{"name": "name", "type": "string"},
@@ -236,4 +243,3 @@ def test_persist_false_no_file(self, wdir):
wallet = EVMWalletProvider(password=PW, private_key=PK, persist=False, wallets_dir=wdir)
assert wallet.address == Account.from_key(PK).address
assert not wdir.exists() # No directory created
-
diff --git a/python/tests/test_wallet_conformance.py b/python/tests/test_wallet_conformance.py
index 92a7e30..d975507 100644
--- a/python/tests/test_wallet_conformance.py
+++ b/python/tests/test_wallet_conformance.py
@@ -34,6 +34,7 @@
from bnbagent.wallets import (
EVMWalletProvider,
ExecutionContext,
+ TurnkeyWalletProvider,
TWAKProvider,
UnsupportedWalletOperation,
WalletProvider,
@@ -51,6 +52,8 @@
)
from bnbagent.wallets.local_executor import LocalExecutor
+from .turnkey_fake import FakeTurnkeyClient
+
PW = "test-secure-password-123"
_TEST_KEY = "0x" + "ab" * 32
@@ -65,6 +68,9 @@
"twak": frozenset(
{SIGN_MESSAGE, BROADCAST_SELF, INTENTS_ERC8004, INTENTS_ERC8183, X402_PAY}
),
+ "turnkey": frozenset(
+ {SIGN_MESSAGE, SIGN_TRANSACTION, SIGN_TYPED_DATA, CALLS_ARBITRARY, PAYMASTER_SPONSOR}
+ ),
}
@@ -94,6 +100,19 @@ def _make_provider(kind: str) -> WalletProvider:
persist=False,
signing_policy=SigningPolicy.permissive(),
)
+ if kind == "turnkey":
+ # Remote signer with the fake in-process enclave (signs with the
+ # shared test key, never dials the network). Permissive policy for
+ # the same reason as evm above.
+ fake = FakeTurnkeyClient(_TEST_KEY)
+ return TurnkeyWalletProvider(
+ organization_id="org-conformance",
+ sign_with=fake.address,
+ api_public_key="02" + "ab" * 32,
+ api_private_key="cd" * 32,
+ signing_policy=SigningPolicy.permissive(),
+ client=fake,
+ )
return TWAKProvider(chain="bsc")
diff --git a/python/tests/test_wallet_factory.py b/python/tests/test_wallet_factory.py
index 958f372..261171d 100644
--- a/python/tests/test_wallet_factory.py
+++ b/python/tests/test_wallet_factory.py
@@ -55,7 +55,20 @@ def test_mpc_is_not_implemented(self):
create_wallet_provider("mpc")
def test_supported_kinds_match_class_attrs(self):
- assert set(SUPPORTED_WALLET_KINDS) == {"evm", "twak", "mpc"}
+ assert set(SUPPORTED_WALLET_KINDS) == {"evm", "twak", "mpc", "turnkey"}
+
+ def test_turnkey_dispatches_to_provider(self):
+ from bnbagent.wallets.turnkey import TurnkeyWalletProvider
+
+ wallet = create_wallet_provider(
+ "turnkey",
+ organization_id="org-123",
+ sign_with="0x" + "7a" * 20,
+ api_public_key="02" + "ab" * 32,
+ api_private_key="cd" * 32,
+ )
+ assert isinstance(wallet, TurnkeyWalletProvider)
+ assert wallet.kind == "turnkey"
class TestIntrospection:
diff --git a/python/tests/turnkey_fake.py b/python/tests/turnkey_fake.py
new file mode 100644
index 0000000..c4f23c0
--- /dev/null
+++ b/python/tests/turnkey_fake.py
@@ -0,0 +1,164 @@
+"""A faithful in-process fake of the Turnkey signing enclave.
+
+Implements the :class:`bnbagent.wallets.turnkey.client.TurnkeyClient`
+surface with ``eth_account`` as the "enclave": signatures are real and
+recoverable, and the EIP-712 path re-parses the provider's JSON payload
+with an independent implementation — so a malformed payload (e.g. a missing
+``EIP712Domain`` entry) fails here the same way it would fail verification
+against the real service.
+
+Never dials the network; used by the provider and conformance suites.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+import rlp
+from eth_account import Account
+from eth_utils import to_checksum_address
+
+_DECIMAL_RE = re.compile(r"^\d+$")
+
+
+def _coerce_document(document: dict[str, Any]) -> dict[str, Any]:
+ """Undo the provider's big-int→decimal-string JSON conversion, type-aware.
+
+ The real enclave parses either spelling for numeric fields (proven by
+ the 2026-07 probes, where ``@turnkey/viem`` sent JS bigints as
+ strings); ``eth_account``'s encoder wants ints. Coercion follows the
+ declared field types — a ``string`` field that happens to contain
+ digits (e.g. ``version: "1"``) must stay a string.
+ """
+ types: dict[str, list[dict[str, str]]] = document.get("types", {})
+
+ def coerce_value(field_type: str, value: Any) -> Any:
+ if field_type.endswith("]"):
+ base = field_type[: field_type.rindex("[")]
+ return [coerce_value(base, item) for item in value]
+ if field_type in types:
+ return coerce_struct(field_type, value)
+ if (
+ field_type.startswith(("uint", "int"))
+ and isinstance(value, str)
+ and _DECIMAL_RE.match(value)
+ ):
+ return int(value)
+ return value
+
+ def coerce_struct(struct_name: str, value: dict[str, Any]) -> dict[str, Any]:
+ fields = {f["name"]: f["type"] for f in types.get(struct_name, [])}
+ return {key: coerce_value(fields.get(key, ""), item) for key, item in value.items()}
+
+ return {
+ **document,
+ "domain": coerce_struct("EIP712Domain", document.get("domain", {})),
+ "message": coerce_struct(str(document.get("primaryType")), document.get("message", {})),
+ }
+
+
+def _int_of(field: bytes) -> int:
+ return int.from_bytes(field, "big") if field else 0
+
+
+def _decode_unsigned_transaction(raw: bytes) -> dict[str, Any]:
+ """Decode the provider's unsigned RLP back into an eth_account tx dict."""
+ if raw[:1] == b"\x02":
+ fields = rlp.decode(raw[1:])
+ assert len(fields) == 9, f"unexpected 1559 unsigned shape: {len(fields)}"
+ tx: dict[str, Any] = {
+ "type": 2,
+ "chainId": _int_of(fields[0]),
+ "nonce": _int_of(fields[1]),
+ "maxPriorityFeePerGas": _int_of(fields[2]),
+ "maxFeePerGas": _int_of(fields[3]),
+ "gas": _int_of(fields[4]),
+ "value": _int_of(fields[6]),
+ "data": "0x" + fields[7].hex(),
+ "accessList": [],
+ }
+ if fields[5]:
+ tx["to"] = to_checksum_address("0x" + fields[5].hex())
+ return tx
+ fields = rlp.decode(raw)
+ assert len(fields) == 9, f"unexpected legacy unsigned shape: {len(fields)}"
+ assert _int_of(fields[7]) == 0 and _int_of(fields[8]) == 0, (
+ "legacy unsigned payload must end with the EIP-155 (chainId, 0, 0) triplet"
+ )
+ tx = {
+ "nonce": _int_of(fields[0]),
+ "gasPrice": _int_of(fields[1]),
+ "gas": _int_of(fields[2]),
+ "value": _int_of(fields[4]),
+ "data": "0x" + fields[5].hex(),
+ "chainId": _int_of(fields[6]),
+ }
+ if fields[3]:
+ tx["to"] = to_checksum_address("0x" + fields[3].hex())
+ return tx
+
+
+class FakeTurnkeyClient:
+ """Signs like the enclave, records like a probe.
+
+ Attributes:
+ raw_payload_calls: Every ``sign_raw_payload`` invocation's kwargs.
+ transaction_calls: Every ``sign_transaction`` unsigned hex.
+ failure: When set, the next call raises it (vendor-error testing).
+ """
+
+ def __init__(self, private_key: str) -> None:
+ self._account = Account.from_key(private_key)
+ self.raw_payload_calls: list[dict[str, Any]] = []
+ self.transaction_calls: list[str] = []
+ self.failure: Exception | None = None
+
+ @property
+ def address(self) -> str:
+ return self._account.address
+
+ def _maybe_fail(self) -> None:
+ if self.failure is not None:
+ failure, self.failure = self.failure, None
+ raise failure
+
+ def sign_raw_payload(
+ self, *, sign_with: str, payload: str, encoding: str, hash_function: str
+ ) -> dict[str, str]:
+ self._maybe_fail()
+ self.raw_payload_calls.append(
+ {
+ "sign_with": sign_with,
+ "payload": payload,
+ "encoding": encoding,
+ "hash_function": hash_function,
+ }
+ )
+ assert hash_function == "HASH_FUNCTION_NO_OP", hash_function
+ if encoding == "PAYLOAD_ENCODING_HEXADECIMAL":
+ digest = bytes.fromhex(payload[2:] if payload.startswith("0x") else payload)
+ signed = self._account.unsafe_sign_hash(digest)
+ elif encoding == "PAYLOAD_ENCODING_EIP712":
+ document = json.loads(payload)
+ assert "EIP712Domain" in document.get("types", {}), (
+ "typed-data payload reached the enclave WITHOUT an "
+ "EIP712Domain entry — the 0.14.x stripping trap"
+ )
+ signed = self._account.sign_typed_data(full_message=_coerce_document(document))
+ else: # pragma: no cover - guards fake misuse
+ raise AssertionError(f"unexpected encoding {encoding!r}")
+ return {
+ "r": format(signed.r, "064x"),
+ "s": format(signed.s, "064x"),
+ # The API returns the recovery id, not 27/28.
+ "v": format(signed.v - 27, "02x"),
+ }
+
+ def sign_transaction(self, *, sign_with: str, unsigned_transaction: str) -> str:
+ self._maybe_fail()
+ self.transaction_calls.append(unsigned_transaction)
+ tx = _decode_unsigned_transaction(bytes.fromhex(unsigned_transaction))
+ signed = self._account.sign_transaction(tx)
+ return bytes(signed.raw_transaction).hex()
diff --git a/python/uv.lock b/python/uv.lock
index 367c538..39f4bd5 100644
--- a/python/uv.lock
+++ b/python/uv.lock
@@ -310,6 +310,7 @@ dependencies = [
[package.optional-dependencies]
dev = [
+ { name = "cryptography" },
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
@@ -326,10 +327,15 @@ examples = [
ipfs = [
{ name = "httpx" },
]
+turnkey = [
+ { name = "cryptography" },
+]
[package.metadata]
requires-dist = [
{ name = "aiosqlite", marker = "extra == 'examples'", specifier = ">=0.19" },
+ { name = "cryptography", marker = "extra == 'dev'", specifier = ">=42.0.0" },
+ { name = "cryptography", marker = "extra == 'turnkey'", specifier = ">=42.0.0" },
{ name = "ddgs", marker = "extra == 'examples'", specifier = ">=6.0" },
{ name = "eth-account", specifier = ">=0.10.0" },
{ name = "fastapi", marker = "extra == 'examples'", specifier = ">=0.104.0" },
@@ -345,7 +351,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'examples'", specifier = ">=0.24.0" },
{ name = "web3", specifier = ">=6.15.0" },
]
-provides-extras = ["dev", "examples", "ipfs"]
+provides-extras = ["dev", "examples", "ipfs", "turnkey"]
[[package]]
name = "certifi"
@@ -356,6 +362,116 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
+[[package]]
+name = "cffi"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" },
+ { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" },
+ { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" },
+ { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" },
+ { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" },
+ { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" },
+ { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" },
+ { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
+ { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
+ { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
+ { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
+ { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
+ { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
+ { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
+ { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
+ { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
+ { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
+ { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
+ { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
+ { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
+]
+
[[package]]
name = "charset-normalizer"
version = "3.4.4"
@@ -552,6 +668,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
+[[package]]
+name = "cryptography"
+version = "50.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" },
+ { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" },
+ { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" },
+ { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" },
+ { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" },
+ { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" },
+ { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" },
+ { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" },
+ { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" },
+ { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" },
+ { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" },
+ { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" },
+ { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" },
+ { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" },
+ { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" },
+ { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" },
+ { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" },
+ { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" },
+ { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" },
+ { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" },
+ { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" },
+ { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" },
+ { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" },
+ { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" },
+]
+
[[package]]
name = "cytoolz"
version = "1.1.0"
@@ -1499,6 +1672,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
]
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
[[package]]
name = "pycryptodome"
version = "3.23.0"
diff --git a/typescript/README.md b/typescript/README.md
index ab94d54..5061275 100644
--- a/typescript/README.md
+++ b/typescript/README.md
@@ -157,15 +157,32 @@ await fundedJobWatcher(
Every protocol client signs through the `WalletProvider` seam - swap the provider, keep the protocol code:
-| | `EVMWalletProvider` | `TWAKProvider` | `AltanaWalletProvider` |
-| --- | --- | --- | --- |
-| Custody | local key, Keystore V3 on disk | [twak CLI](../docs/twak.md) keystore (`~/.twak`) + OS keychain; the key never enters this process | EIP-7702 wallet == your EOA; the [Altana capability reference](../docs/altana.md) covers the relay and session model |
-| Capabilities | `sign.message/transaction/typed_data`, `calls.arbitrary`, `paymaster.sponsor` | `sign.message`, `broadcast.self`, `intents.erc8004`, `intents.erc8183`, `x402.pay` (no raw signing, no arbitrary calls) | `broadcast.self`, `calls.arbitrary`, `intents.erc8004`, `intents.erc8183` (+ `x402.pay` in session mode) |
-| Agent containment | `SigningPolicy` (in-process) | fixed command menu + out-of-process custody; twak's own `--max-payment` hard cap | on-chain session keys: call whitelist + spend caps + expiry, revocable in one tx |
-| Gas | self-paid or MegaFuel-sponsored | mainnet auto-sponsored by twak; the SDK forwards its paymaster as `--paymaster-url` (twak >= v0.20.0), so sponsored testnet writes work | relay fronts gas, recovers it from the wallet (MegaFuel not involved) |
-| ERC-8183 quote signing | EIP-191 via `walletProvider` | - | ERC-1271 via `sessionQuoteSigner()`; no admin EOA or generic message-signing authority in the agent |
-| x402 payments | `X402Signer` | Delegated `TwakX402Payer` (`makeX402Payer()`, five-point quote precheck) | Session-key payer (`makeX402Payer()`, SDK >= 0.4.0) after a one-time admin setup: `approveX402SignatureChecker` + bounded `setPermit2Allowance`; **receiving** at the wallet works fine |
-| Extra install | - | `npm i @trustwallet/cli` (>= 0.20.0; the local `node_modules/.bin/twak` is auto-resolved) | `pnpm add @altananetwork/sdk` (optional peer, GPL-3.0-or-later, lazily imported) |
+| | `EVMWalletProvider` | `TWAKProvider` | `AltanaWalletProvider` | `TurnkeyWalletProvider` |
+| --- | --- | --- | --- | --- |
+| Custody | local key, Keystore V3 on disk | [twak CLI](../docs/twak.md) keystore (`~/.twak`) + OS keychain; the key never enters this process | EIP-7702 wallet == your EOA; the [Altana capability reference](../docs/altana.md) covers the relay and session model | key generated + held in [Turnkey](https://docs.turnkey.com)'s AWS Nitro enclave, never leaves it; the agent holds only a local P-256 API key that stamps each request |
+| Capabilities | `sign.message/transaction/typed_data`, `calls.arbitrary`, `paymaster.sponsor` | `sign.message`, `broadcast.self`, `intents.erc8004`, `intents.erc8183`, `x402.pay` (no raw signing, no arbitrary calls) | `broadcast.self`, `calls.arbitrary`, `intents.erc8004`, `intents.erc8183` (+ `x402.pay` in session mode) | `sign.message/transaction/typed_data`, `calls.arbitrary`, `paymaster.sponsor` (same surface as EVM, remote signer) |
+| Agent containment | `SigningPolicy` (in-process) | fixed command menu + out-of-process custody; twak's own `--max-payment` hard cap | on-chain session keys: call whitelist + spend caps + expiry, revocable in one tx | `SigningPolicy` (in-process, pre-billing) + Turnkey's server-side policy engine — **root API keys bypass the server layer entirely; production needs a non-root API user + explicit ALLOW policy** |
+| Gas | self-paid or MegaFuel-sponsored | mainnet auto-sponsored by twak; the SDK forwards its paymaster as `--paymaster-url` (twak >= v0.20.0), so sponsored testnet writes work | relay fronts gas, recovers it from the wallet (MegaFuel not involved) | self-paid or MegaFuel-sponsored (broadcast is always the SDK's own RPC; Turnkey's managed broadcast is a paid feature the provider never uses) |
+| ERC-8183 quote signing | EIP-191 via `walletProvider` | - | ERC-1271 via `sessionQuoteSigner()`; no admin EOA or generic message-signing authority in the agent | EIP-191 via `walletProvider` (1 billed signature per quote) |
+| x402 payments | `X402Signer` | Delegated `TwakX402Payer` (`makeX402Payer()`, five-point quote precheck) | Session-key payer (`makeX402Payer()`, SDK >= 0.4.0) after a one-time admin setup: `approveX402SignatureChecker` + bounded `setPermit2Allowance`; **receiving** at the wallet works fine | `X402Signer` — works, but $0.10/signature (PAYG) at 1 req/s makes high-frequency buying uneconomical; seller/low-frequency signing is the sweet spot |
+| Extra install | - | `npm i @trustwallet/cli` (>= 0.20.0; the local `node_modules/.bin/twak` is auto-resolved) | `pnpm add @altananetwork/sdk` (optional peer, GPL-3.0-or-later, lazily imported) | `pnpm add @turnkey/sdk-server @turnkey/viem` (optional peers, lazily imported) |
+
+Turnkey quick start (remote enclave signing; every successful signature is billed — free tier 25/month at 1 request/second, pay-as-you-go $0.10/signature):
+
+```ts
+import { TurnkeyWalletProvider } from "@bnbagent/sdk/wallets";
+
+// 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)
+const wallet = TurnkeyWalletProvider.fromEnv({ expectedChainId: 97 });
+
+const jobs = await ERC8183Client.create({
+ walletProvider: wallet,
+ network: "bsc-testnet",
+});
+```
+
+The provider is a pure signer: ERC-8004/8183 writes ride the default `LocalExecutor`, x402 rides `X402Signer`, and the MegaFuel paymaster path works unchanged (enclave-verified with `gasPrice=0` legacy signing). All SDK-side guards — `SigningPolicy`, the `expectedChainId` pin, input validation — run *before* the billable API call, so a refusal never costs quota. One vendor quirk is patched inside `signTypedData`: `@turnkey/viem` <= 0.14.34 silently serializes the EIP-712 domain as `{}` when the `types` object lacks an explicit `EIP712Domain` entry, so the provider always injects it (the signature would otherwise bind an empty domain). Live verification: `TURNKEY_E2E=1 pnpm run e2e:turnkey` (5 billed signatures; see `examples/turnkey/e2e.ts`).
Session quick start (admin grants once, agent runs with the session):
diff --git a/typescript/examples/turnkey/e2e.ts b/typescript/examples/turnkey/e2e.ts
new file mode 100644
index 0000000..fe9cd16
--- /dev/null
+++ b/typescript/examples/turnkey/e2e.ts
@@ -0,0 +1,241 @@
+/**
+ * Live BSC-testnet E2E for the Turnkey wallet provider — 5 gated steps.
+ *
+ * ⚠️ SPENDS REAL MONEY-SHAPED RESOURCES. Every successful Turnkey signature
+ * is BILLED against the org's quota (free tier: 25 signatures/month at
+ * 1 request/second; pay-as-you-go $0.10/signature). A full run consumes
+ * exactly 5 billed signatures plus a few 10⁻⁵ tBNB of gas for two
+ * self-transfers. Calls are strictly serial with a ≥1.1 s gap (free-tier
+ * rate limit); the chain-id assertion runs BEFORE anything billable.
+ *
+ * ⚠️ Production posture reminder: run this with a NON-ROOT API user
+ * restricted by an explicit ALLOW policy — a root user's API key bypasses
+ * ALL Turnkey server-side policies (root quorum). The SDK-side
+ * SigningPolicy still applies either way.
+ *
+ * What the steps prove (mapping to the 2026-07-24 probe findings):
+ * 1. EIP-191 blind digest signing recovers to the Turnkey address.
+ * 2. EIP-712 signing binds the REAL domain — the live regression check
+ * for the `@turnkey/viem` ≤0.14.34 EIP712Domain-stripping trap the
+ * provider patches (an empty-domain signature would fail the
+ * recoverTypedDataAddress comparison here).
+ * 3. A legacy (gasPrice) self-transfer signs, broadcasts over our own
+ * RPC (managed broadcast is paywalled — never used) and lands.
+ * 4. An EIP-1559 self-transfer does the same.
+ * 5. `X402Signer.signPayment` works against the provider unchanged —
+ * the "implement signTypedData and x402 comes free" contract.
+ * ERC-8004 registration through the full ContractInterface→LocalExecutor
+ * pipeline was probe-verified on-chain (agentId=1727) and is not repeated
+ * here to protect the signature budget.
+ *
+ * Usage (env in `typescript/.env`, never committed):
+ * TURNKEY_E2E=1
+ * TURNKEY_API_PUBLIC_KEY=... TURNKEY_API_PRIVATE_KEY=...
+ * TURNKEY_ORG_ID=... TURNKEY_SIGN_WITH=0x...
+ * # optional: TURNKEY_API_BASE_URL, RPC_URL
+ * pnpm -C typescript run e2e:turnkey
+ *
+ * The TURNKEY_SIGN_WITH address needs a little tBNB for gas (~0.001).
+ * Exits 0 only when every step PASSes; any FAIL aborts and exits 1.
+ * Deliberately NOT part of CI.
+ */
+
+import {
+ http,
+ createPublicClient,
+ formatEther,
+ hashMessage,
+ recoverMessageAddress,
+ recoverTypedDataAddress,
+} from "viem";
+import { NETWORKS } from "../../src/config.js";
+import { loadEnv } from "../../src/core/env.js";
+import { getEnv } from "../../src/core/envUtil.js";
+import { BNB_CHAIN_ADDRESSES } from "../../src/networks/addresses.js";
+import { TurnkeyWalletProvider } from "../../src/wallets/turnkey/provider.js";
+import { X402Signer } from "../../src/x402/signer.js";
+
+const CHAIN_ID = 97;
+const GAP_MS = 1100; // free tier: 1 request/second — stay under it
+const SIGNATURE_BUDGET = 5;
+
+let billedSignatures = 0;
+let lastVendorCallAt = 0;
+
+/** Serialize vendor calls (≥1.1 s apart) and count the signature budget. */
+async function vendor(label: string, fn: () => Promise): Promise {
+ if (billedSignatures >= SIGNATURE_BUDGET) {
+ throw new Error(
+ `signature budget of ${SIGNATURE_BUDGET} exhausted before '${label}'`,
+ );
+ }
+ const wait = lastVendorCallAt + GAP_MS - Date.now();
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
+ lastVendorCallAt = Date.now();
+ const result = await fn();
+ billedSignatures += 1;
+ console.log(` [budget] ${billedSignatures}/${SIGNATURE_BUDGET} billed signatures`);
+ return result;
+}
+
+function pass(step: string, detail: string): void {
+ console.log(`✅ ${step} — ${detail}`);
+}
+
+async function main(): Promise {
+ loadEnv();
+ if (getEnv("TURNKEY_E2E") !== "1") {
+ console.log(
+ "TURNKEY_E2E != 1 — refusing to run (this script consumes billed Turnkey signatures and testnet gas). Set TURNKEY_E2E=1 plus the TURNKEY_* env vars in typescript/.env to opt in.",
+ );
+ return;
+ }
+
+ const network = NETWORKS["bsc-testnet"];
+ if (!network) throw new Error("bsc-testnet preset missing");
+ const paymentToken = BNB_CHAIN_ADDRESSES[CHAIN_ID]?.paymentToken;
+ if (!paymentToken) throw new Error(`no payment token registered for chain ${CHAIN_ID}`);
+ const rpcUrl = getEnv("RPC_URL") ?? network.rpcUrl;
+ const client = createPublicClient({ transport: http(rpcUrl) });
+
+ // ── Gate 0: chain identity, BEFORE anything billable ────────────────
+ const chainId = await client.getChainId();
+ if (chainId !== CHAIN_ID) {
+ throw new Error(`RPC ${rpcUrl} reports chainId=${chainId}, need ${CHAIN_ID}`);
+ }
+
+ const wallet = TurnkeyWalletProvider.fromEnv({ expectedChainId: CHAIN_ID });
+ const address = wallet.address;
+ const balance = await client.getBalance({ address });
+ console.log(
+ `turnkey e2e: signer=${address} balance=${formatEther(balance)} tBNB rpc=${rpcUrl}`,
+ );
+ if (balance < 300_000_000_000_000n) {
+ throw new Error(
+ `signer balance ${formatEther(balance)} tBNB is below the ~0.0003 needed for two self-transfers — fund ${address} first`,
+ );
+ }
+
+ // ── 1. EIP-191 ──────────────────────────────────────────────────────
+ const message = `turnkey-e2e ${new Date().toISOString()}`;
+ const signed191 = await vendor("eip191", () => wallet.signMessage(message));
+ if (signed191.messageHash !== hashMessage(message)) {
+ throw new Error("191 digest mismatch");
+ }
+ const recovered191 = await recoverMessageAddress({
+ message,
+ signature: signed191.signature,
+ });
+ if (recovered191 !== address) {
+ throw new Error(`191 recovered ${recovered191}, want ${address}`);
+ }
+ pass("1/5 EIP-191", `recovered ${recovered191}`);
+
+ // ── 2. EIP-712 with domain binding (the stripping-trap live check) ──
+ const nowSec = Math.floor(Date.now() / 1000);
+ const domain = {
+ name: "United Stables",
+ version: "1",
+ chainId: CHAIN_ID,
+ verifyingContract: paymentToken,
+ };
+ const types = {
+ TransferWithAuthorization: [
+ { name: "from", type: "address" },
+ { name: "to", type: "address" },
+ { name: "value", type: "uint256" },
+ { name: "validAfter", type: "uint256" },
+ { name: "validBefore", type: "uint256" },
+ { name: "nonce", type: "bytes32" },
+ ],
+ };
+ const message712 = {
+ from: address,
+ to: address,
+ value: 1n,
+ validAfter: BigInt(nowSec - 10),
+ validBefore: BigInt(nowSec + 580),
+ nonce: `0x${crypto.getRandomValues(new Uint8Array(32)).reduce((acc, b) => acc + b.toString(16).padStart(2, "0"), "")}` as `0x${string}`,
+ };
+ const signed712 = await vendor("eip712", () =>
+ wallet.signTypedData(domain, types, message712),
+ );
+ const recovered712 = await recoverTypedDataAddress({
+ domain,
+ types,
+ primaryType: "TransferWithAuthorization",
+ message: message712,
+ signature: signed712.signature,
+ });
+ if (recovered712 !== address) {
+ throw new Error(
+ `712 recovered ${recovered712} against the REAL domain, want ${address} — empty-domain binding (the 0.14.x trap) would fail exactly here`,
+ );
+ }
+ pass("2/5 EIP-712", `real-domain recovery ${recovered712}`);
+
+ // ── 3+4. legacy and 1559 self-transfers over our own RPC ────────────
+ const gasPrice = await client.getGasPrice();
+ let nonce = await client.getTransactionCount({ address, blockTag: "pending" });
+
+ for (const [step, tx] of [
+ [
+ "3/5 legacy tx",
+ { chainId: CHAIN_ID, to: address, value: 1n, gas: 21_000n, nonce: nonce++, gasPrice },
+ ],
+ [
+ "4/5 eip-1559 tx",
+ {
+ chainId: CHAIN_ID,
+ to: address,
+ value: 1n,
+ gas: 21_000n,
+ nonce: nonce++,
+ maxFeePerGas: gasPrice * 2n,
+ maxPriorityFeePerGas: gasPrice,
+ },
+ ],
+ ] as const) {
+ const signedTx = await vendor(step, () =>
+ wallet.signTransaction(tx as Parameters[0]),
+ );
+ const hash = await client.sendRawTransaction({
+ serializedTransaction: signedTx.rawTransaction,
+ });
+ const receipt = await client.waitForTransactionReceipt({ hash, timeout: 120_000 });
+ if (receipt.status !== "success") throw new Error(`${step}: reverted ${hash}`);
+ pass(step, `landed ${hash}`);
+ }
+
+ // ── 5. x402 free-ride through X402Signer ────────────────────────────
+ const signer = new X402Signer(wallet, {
+ maxValuePerCall: { [paymentToken]: 10n },
+ sessionBudget: { [paymentToken]: 10n },
+ });
+ const payment = await vendor("x402 signPayment", () =>
+ signer.signPayment({
+ domain: domain as Record,
+ types,
+ message: { ...message712, value: 10n },
+ expectedTo: address,
+ }),
+ );
+ const recoveredPay = await recoverTypedDataAddress({
+ domain,
+ types,
+ primaryType: "TransferWithAuthorization",
+ message: { ...message712, value: 10n },
+ signature: payment.signature as `0x${string}`,
+ });
+ if (recoveredPay !== address) {
+ throw new Error(`x402 payment recovered ${recoveredPay}, want ${address}`);
+ }
+ pass("5/5 X402Signer", "payment signature recovers; policy + budget gates passed");
+
+ console.log(`\nall 5 steps PASS — ${billedSignatures} billed signatures used`);
+}
+
+main().catch((error) => {
+ console.error("E2E FAILED:", error instanceof Error ? error.message : error);
+ process.exitCode = 1;
+});
diff --git a/typescript/package.json b/typescript/package.json
index 66ef3ee..66c4eb8 100644
--- a/typescript/package.json
+++ b/typescript/package.json
@@ -1,6 +1,6 @@
{
"name": "@bnbagent/sdk",
- "version": "0.5.0",
+ "version": "0.5.1",
"description": "TypeScript SDK for building on-chain AI agents on BNB Chain (ERC-8004 identity, ERC-8183 agentic commerce, x402 payments).",
"license": "MIT",
"repository": {
@@ -83,17 +83,20 @@
"example:a2a-server": "tsx examples/a2a-agent/src/server.ts",
"example:a2a-buyer": "tsx examples/a2a-agent/scripts/buyer.ts",
"smoke:testnet": "tsx examples/smoke/readonly.ts",
- "e2e:altana": "tsx examples/altana/e2e.ts"
+ "e2e:altana": "tsx examples/altana/e2e.ts",
+ "e2e:turnkey": "tsx examples/turnkey/e2e.ts"
},
"dependencies": {
"@noble/ciphers": "^1.0.0",
"@noble/hashes": "^1.4.0",
"dotenv": "^16.4.0",
- "viem": "^2.21.0"
+ "viem": "^2.24.2"
},
"devDependencies": {
"@altananetwork/sdk": "0.5.1",
"@biomejs/biome": "^1.8.0",
+ "@turnkey/sdk-server": "8.1.0",
+ "@turnkey/viem": "0.14.34",
"@types/node": "^20.0.0",
"tsup": "^8.0.0",
"tsx": "^4.23.0",
@@ -101,11 +104,19 @@
"vitest": "^2.0.0"
},
"peerDependencies": {
- "@altananetwork/sdk": ">=0.3.3 <0.6.0"
+ "@altananetwork/sdk": ">=0.3.3 <0.6.0",
+ "@turnkey/sdk-server": ">=7.0.0 <9.0.0",
+ "@turnkey/viem": ">=0.14.32 <0.15.0"
},
"peerDependenciesMeta": {
"@altananetwork/sdk": {
"optional": true
+ },
+ "@turnkey/sdk-server": {
+ "optional": true
+ },
+ "@turnkey/viem": {
+ "optional": true
}
},
"pnpm": {
diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml
index 11cffbd..a111ae2 100644
--- a/typescript/pnpm-lock.yaml
+++ b/typescript/pnpm-lock.yaml
@@ -18,7 +18,7 @@ importers:
specifier: ^16.4.0
version: 16.6.1
viem:
- specifier: ^2.21.0
+ specifier: ^2.24.2
version: 2.54.6(typescript@5.9.3)(zod@4.4.3)
devDependencies:
'@altananetwork/sdk':
@@ -27,6 +27,12 @@ importers:
'@biomejs/biome':
specifier: ^1.8.0
version: 1.9.4
+ '@turnkey/sdk-server':
+ specifier: 8.1.0
+ version: 8.1.0(typescript@5.9.3)(zod@4.4.3)
+ '@turnkey/viem':
+ specifier: 0.14.34
+ version: 0.14.34(typescript@5.9.3)(viem@2.54.6(typescript@5.9.3)(zod@4.4.3))(zod@4.4.3)
'@types/node':
specifier: ^20.0.0
version: 20.19.43
@@ -554,6 +560,26 @@ packages:
cpu: [x64]
os: [win32]
+ '@hpke/chacha20poly1305@1.8.0':
+ resolution: {integrity: sha512-FcBfAQ+Y99vMNJP2yrZ9wpL8V0GOwp1+zMyzvc6alasrBygfFjFm1yeUtyADJCu/27C3Lm5mJzx6u7pwg+cX5w==}
+ engines: {node: '>=16.0.0'}
+
+ '@hpke/common@1.10.1':
+ resolution: {integrity: sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==}
+ engines: {node: '>=16.0.0'}
+
+ '@hpke/core@1.9.0':
+ resolution: {integrity: sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==}
+ engines: {node: '>=16.0.0'}
+
+ '@hpke/dhkem-x25519@1.8.0':
+ resolution: {integrity: sha512-S1MWWkAfu+TFxySgv5+2P3O4Mx/jk7BsoplzQaA1s3sfUJVJ2UsZsSzSsMc+FXJumLXncoJFlO6mK6mDGspfmA==}
+ engines: {node: '>=16.0.0'}
+
+ '@hpke/dhkem-x448@1.8.0':
+ resolution: {integrity: sha512-mFfnZfgp4OKkUIS/FKikfUgdnDKRy25ytCKBQiV+N+HbYy3I4v4ZCPBQ69QL+TYmKmCZJeUEnYeS5K+OBRP+Eg==}
+ engines: {node: '>=16.0.0'}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -567,18 +593,84 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@msgpack/msgpack@3.1.3':
+ resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==}
+ engines: {node: '>= 18'}
+
'@noble/ciphers@1.3.0':
resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==}
engines: {node: ^14.21.3 || >=16}
+ '@noble/curves@1.2.0':
+ resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
+
+ '@noble/curves@1.8.0':
+ resolution: {integrity: sha512-j84kjAbzEnQHaSIhRPUmB3/eVXu2k3dKPl2LOrR8fSOIL+89U+7lV117EWHtq/GHM3ReGHM46iRBdZfpc4HRUQ==}
+ engines: {node: ^14.21.3 || >=16}
+
+ '@noble/curves@1.9.0':
+ resolution: {integrity: sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==}
+ engines: {node: ^14.21.3 || >=16}
+
'@noble/curves@1.9.1':
resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==}
engines: {node: ^14.21.3 || >=16}
+ '@noble/curves@1.9.7':
+ resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==}
+ engines: {node: ^14.21.3 || >=16}
+
+ '@noble/hashes@1.3.2':
+ resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
+ engines: {node: '>= 16'}
+
+ '@noble/hashes@1.7.0':
+ resolution: {integrity: sha512-HXydb0DgzTpDPwbVeDGCG1gIu7X6+AuU6Zl6av/E/KG8LMsvPntvq+w17CHRpKBmN6Ybdrt1eP3k4cj8DJa78w==}
+ engines: {node: ^14.21.3 || >=16}
+
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
+ '@openzeppelin/contracts@5.6.1':
+ resolution: {integrity: sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w==}
+
+ '@peculiar/asn1-cms@2.8.0':
+ resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==}
+
+ '@peculiar/asn1-csr@2.8.0':
+ resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==}
+
+ '@peculiar/asn1-ecc@2.8.0':
+ resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==}
+
+ '@peculiar/asn1-pfx@2.8.0':
+ resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==}
+
+ '@peculiar/asn1-pkcs8@2.8.0':
+ resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==}
+
+ '@peculiar/asn1-pkcs9@2.8.0':
+ resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==}
+
+ '@peculiar/asn1-rsa@2.8.0':
+ resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==}
+
+ '@peculiar/asn1-schema@2.8.0':
+ resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==}
+
+ '@peculiar/asn1-x509-attr@2.8.0':
+ resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==}
+
+ '@peculiar/asn1-x509@2.8.0':
+ resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==}
+
+ '@peculiar/utils@2.0.3':
+ resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==}
+
+ '@peculiar/x509@1.12.3':
+ resolution: {integrity: sha512-+Mzq+W7cNEKfkNZzyLl6A6ffqc3r21HGZUezgfKxpZrkORfOqgRXnS80Zu0IV6a9Ue9QBJeKD7kN0iWfc3bhRQ==}
+
'@rollup/rollup-android-arm-eabi@4.62.2':
resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==}
cpu: [arm]
@@ -713,12 +805,79 @@ packages:
'@scure/bip39@1.6.0':
resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==}
+ '@turnkey/api-key-stamper@0.6.10':
+ resolution: {integrity: sha512-Eczvn1cFg+4tyTjGxVRH64lE/qYjDbx8ynfrLYU8x6n0+ooD9iujXSAzZnbqQcL690D0lje/VrEeOAAEOcE10A==}
+ engines: {node: '>=18.0.0'}
+
+ '@turnkey/core@2.5.0':
+ resolution: {integrity: sha512-QakvwL5NrruoBM3j8kPXjgQGXFnAyxwa4mNE3v6GgX6URv8I9Asq7gkiYKx/1LFPc63Ra0w/Q6fSOEn47wGNbg==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ '@react-native-async-storage/async-storage': ^2.2.0
+ '@turnkey/react-native-passkey-stamper': 1.2.19
+ react-native-keychain: ^8.1.0 || ^9.2.2 || ^10.0.0
+ peerDependenciesMeta:
+ '@react-native-async-storage/async-storage':
+ optional: true
+ '@turnkey/react-native-passkey-stamper':
+ optional: true
+ react-native-keychain:
+ optional: true
+
+ '@turnkey/crypto@2.11.1':
+ resolution: {integrity: sha512-RGMqkmmxUTA2ipPCL2CohVtALn7kYAcTEpqJqU5iCEbHXinxIH1pPpXVYP+ojEv/lCei2vCXutmRBkb7UzK5fg==}
+ engines: {node: '>=18.0.0'}
+
+ '@turnkey/encoding@0.6.0':
+ resolution: {integrity: sha512-IC8qXvy36+iGAeiaVIuJvB35uU2Ld/RAWI/DRTKS+ttBej0GXhOn48Ouu5mlca4jt8ZEuwXmDVv74A8uBQclsA==}
+ engines: {node: '>=18.0.0'}
+
+ '@turnkey/http@6.1.0':
+ resolution: {integrity: sha512-nECQCq9qokjH4vVkyVn6XtbszVQ4w6UzgDOGguONcHDRYkFN4MvXkhugkIxr3oFjhWHcdzKAN00cCwPZaWV9HA==}
+ engines: {node: '>=18.0.0'}
+
+ '@turnkey/iframe-stamper@2.11.1':
+ resolution: {integrity: sha512-S9BoAIyVBH1BurTKsX/YzjVrEhW6Zn0A/9WPfhhC9YA/9RDELP4wNL3jUcijJWgT9OO6BRbRDUs81FrC0ruLww==}
+ engines: {node: '>=18.0.0'}
+
+ '@turnkey/indexed-db-stamper@1.3.4':
+ resolution: {integrity: sha512-AsPZVsacGaIRCK2DAqZPFR8Ou8jpP5fJYgEb/Fq/fMP/cwBA9wNj4MXwWT9zimler2CnFf3l4YmOnrFtw17qMA==}
+ engines: {node: '>=18.0.0'}
+
+ '@turnkey/sdk-browser@8.1.0':
+ resolution: {integrity: sha512-DPAdIlPjCJtrtiapSIcCeoXQL0Q1FxtNEsipRhTS1VcURk4V10Rk7vcOaEEPLIe7ZwgxiZ656QLtuCpoy+VXPQ==}
+ engines: {node: '>=18.0.0'}
+
+ '@turnkey/sdk-server@8.1.0':
+ resolution: {integrity: sha512-6LH7GaXCOF++LRl65A3EgkRD/ZdoSNrc2TXXO3s4hh019cDRAKwefBLh1g2ngEnzWEBtH/7UUZUzssPpnyj/mw==}
+ engines: {node: '>=18.0.0'}
+
+ '@turnkey/sdk-types@1.4.0':
+ resolution: {integrity: sha512-0vra2FyoszSpysJgIBJtiY/1aeRVsGuc16bMwj7e6CwGbh/TnDDbUAM5zPCWEOPqGtQZNDsssJW6q4wSy/x+ag==}
+ engines: {node: '>=18.0.0'}
+
+ '@turnkey/viem@0.14.34':
+ resolution: {integrity: sha512-dxvyou7JaUU1KORGXTaR/UwrEvbXhNg4EwpQW2DA8ia5plw/CNQtmx8YjyGYSFWz2gzBxDGybro3fWY+uholSg==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ viem: ^1.16.6 || ^2.24.2
+
+ '@turnkey/wallet-stamper@1.1.22':
+ resolution: {integrity: sha512-5vvifKGXiMJnHsk4aZTWct41EBE6JFH7cSUcNiVci6InTdMHZqzzH7awcyW0z8cpBhjYQdz7yRM1oac5FmIYWg==}
+
+ '@turnkey/webauthn-stamper@0.6.0':
+ resolution: {integrity: sha512-jdN17QEnn7RBykEOhtKIialWmDjnDAH8DzbyITwn8jsKcwT1TBNYge89hTUTjbdsDLBAqQw8cHujPdy0RaAqvw==}
+ engines: {node: '>=18.0.0'}
+
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@20.19.43':
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
+ '@types/node@22.7.5':
+ resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==}
+
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@@ -763,6 +922,77 @@ packages:
typescript:
optional: true
+ '@wallet-standard/app@1.1.1':
+ resolution: {integrity: sha512-WDGwoByhP5gwHH01r5EaLgQdLVkACPCdOMQhmhn8rsm10h/siSgTorShzBxrn0ExSPof+Lu+C3TfgqBrPa1xoQ==}
+ engines: {node: '>=22'}
+
+ '@wallet-standard/base@1.1.1':
+ resolution: {integrity: sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ==}
+ engines: {node: '>=22'}
+
+ '@walletconnect/core@2.23.10':
+ resolution: {integrity: sha512-Qq2btHEoCgruvkZCWLSrVsvg/dYbM9Z045qeClwhJR4meL32jbIRT0mKWjf0HkRc2LA82MsnszVnfuZl3yWl5A==}
+ engines: {node: '>=18.20.8'}
+
+ '@walletconnect/environment@1.0.1':
+ resolution: {integrity: sha512-T426LLZtHj8e8rYnKfzsw1aG6+M0BT1ZxayMdv/p8yM0MU+eJDISqNY3/bccxRr4LrF9csq02Rhqt08Ibl0VRg==}
+
+ '@walletconnect/events@1.0.1':
+ resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==}
+
+ '@walletconnect/heartbeat@1.2.2':
+ resolution: {integrity: sha512-uASiRmC5MwhuRuf05vq4AT48Pq8RMi876zV8rr8cV969uTOzWdB/k+Lj5yI2PBtB1bGQisGen7MM1GcZlQTBXw==}
+
+ '@walletconnect/jsonrpc-provider@1.0.14':
+ resolution: {integrity: sha512-rtsNY1XqHvWj0EtITNeuf8PHMvlCLiS3EjQL+WOkxEOA4KPxsohFnBDeyPYiNm4ZvkQdLnece36opYidmtbmow==}
+
+ '@walletconnect/jsonrpc-types@1.0.4':
+ resolution: {integrity: sha512-P6679fG/M+wuWg9TY8mh6xFSdYnFyFjwFelxyISxMDrlbXokorEVXYOxiqEbrU3x1BmBoCAJJ+vtEaEoMlpCBQ==}
+
+ '@walletconnect/jsonrpc-utils@1.0.8':
+ resolution: {integrity: sha512-vdeb03bD8VzJUL6ZtzRYsFMq1eZQcM3EAzT0a3st59dyLfJ0wq+tKMpmGH7HlB7waD858UWgfIcudbPFsbzVdw==}
+
+ '@walletconnect/jsonrpc-ws-connection@1.0.16':
+ resolution: {integrity: sha512-G81JmsMqh5nJheE1mPst1W0WfVv0SG3N7JggwLLGnI7iuDZJq8cRJvQwLGKHn5H1WTW7DEPCo00zz5w62AbL3Q==}
+
+ '@walletconnect/keyvaluestorage@1.1.1':
+ resolution: {integrity: sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==}
+ peerDependencies:
+ '@react-native-async-storage/async-storage': 1.x
+ peerDependenciesMeta:
+ '@react-native-async-storage/async-storage':
+ optional: true
+
+ '@walletconnect/logger@3.0.2':
+ resolution: {integrity: sha512-7wR3wAwJTOmX4gbcUZcFMov8fjftY05+5cO/d4cpDD8wDzJ+cIlKdYOXaXfxHLSYeDazMXIsxMYjHYVDfkx+nA==}
+
+ '@walletconnect/relay-api@1.0.11':
+ resolution: {integrity: sha512-tLPErkze/HmC9aCmdZOhtVmYZq1wKfWTJtygQHoWtgg722Jd4homo54Cs4ak2RUFUZIGO2RsOpIcWipaua5D5Q==}
+
+ '@walletconnect/relay-auth@1.1.0':
+ resolution: {integrity: sha512-qFw+a9uRz26jRCDgL7Q5TA9qYIgcNY8jpJzI1zAWNZ8i7mQjaijRnWFKsCHAU9CyGjvt6RKrRXyFtFOpWTVmCQ==}
+
+ '@walletconnect/safe-json@1.0.2':
+ resolution: {integrity: sha512-Ogb7I27kZ3LPC3ibn8ldyUr5544t3/STow9+lzz7Sfo808YD7SBWk7SAsdBFlYgP2zDRy2hS3sKRcuSRM0OTmA==}
+
+ '@walletconnect/sign-client@2.23.10':
+ resolution: {integrity: sha512-vO7DGRRmKo+rykmjVyQR1aM4I2nbk9kJ6olbxgjFRR6Jdhy+Kz+zgN7Ce5xVhPfWYVu4bV/XhOQxhvnQw7S5ng==}
+
+ '@walletconnect/time@1.0.2':
+ resolution: {integrity: sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==}
+
+ '@walletconnect/types@2.23.10':
+ resolution: {integrity: sha512-XP8d41979anTrc1OJF3ISF+g81cvp1wim+ObdNnbcaT/jhwLwv+0T7rRe9VwRv+h8EaRgLyeb5YGy7oJ49vxVg==}
+
+ '@walletconnect/utils@2.23.10':
+ resolution: {integrity: sha512-b1c9FRF2g7vNnz66oLW5WZD2VCMrbu9xhpmwJJwqGarBiGW7cY8NbUtS9/w2/qc0vsBVKJ/bzDn4TGjpELU6aQ==}
+
+ '@walletconnect/window-getters@1.0.1':
+ resolution: {integrity: sha512-vHp+HqzGxORPAN8gY03qnbTMnhqIwjeRJNOMOAzePRg4xVEEE2WvYsI9G2NMjOknA8hnuYbU3/hwLcKbjhc8+Q==}
+
+ '@walletconnect/window-metadata@1.0.1':
+ resolution: {integrity: sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==}
+
abitype@1.2.3:
resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==}
peerDependencies:
@@ -779,13 +1009,49 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
+ aes-js@4.0.0-beta.5:
+ resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
+
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
+ anymatch@3.1.3:
+ resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
+ engines: {node: '>= 8'}
+
+ asn1js@3.0.10:
+ resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==}
+ engines: {node: '>=12.0.0'}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ atomic-sleep@1.0.0:
+ resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
+ engines: {node: '>=8.0.0'}
+
+ base-x@5.0.1:
+ resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
+
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
+ blakejs@1.2.1:
+ resolution: {integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==}
+
+ borsh@2.0.0:
+ resolution: {integrity: sha512-kc9+BgR3zz9+cjbwM8ODoUB4fs3X3I5A/HtX7LZKxCLaMrEeDFoBpnhZY//DTS1VZBSs6S5v46RZRbZjRFspEg==}
+
+ bs58@6.0.0:
+ resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==}
+
+ bs58check@4.0.0:
+ resolution: {integrity: sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==}
+
+ buffer@6.0.3:
+ resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -796,6 +1062,9 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
+ cbor-js@0.1.0:
+ resolution: {integrity: sha512-7sQ/TvDZPl7csT1Sif9G0+MA0I0JOVah8+wWlJVQdVEgIbCzlN/ab3x+uvMNsc34TUvO6osQTAmB2ls80JX6tw==}
+
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -808,6 +1077,10 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
+ chokidar@5.0.0:
+ resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
+ engines: {node: '>= 20.19.0'}
+
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@@ -819,6 +1092,18 @@ packages:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
+ cookie-es@1.2.3:
+ resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==}
+
+ cross-fetch@3.2.0:
+ resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==}
+
+ cross-fetch@4.1.0:
+ resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==}
+
+ crossws@0.3.5:
+ resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -832,6 +1117,15 @@ packages:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
+ defu@6.1.7:
+ resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
+
+ destr@2.0.5:
+ resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
+
+ detect-browser@5.3.0:
+ resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==}
+
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -839,6 +1133,9 @@ packages:
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
+ es-toolkit@1.45.1:
+ resolution: {integrity: sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==}
+
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -857,9 +1154,17 @@ packages:
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+ ethers@6.17.0:
+ resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==}
+ engines: {node: '>=14.0.0'}
+
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
+ events@3.3.0:
+ resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
+ engines: {node: '>=0.8.x'}
+
expect-type@1.4.0:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
@@ -881,13 +1186,26 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ h3@1.15.11:
+ resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==}
+
hono@4.12.29:
resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==}
engines: {node: '>=16.9.0'}
+ hpke-js@1.8.0:
+ resolution: {integrity: sha512-N0PFQlUQsIPS9++nUNn2ZsxTPSv8pONyyrXIGZl0iiherRfS0XW1SvTd+RmepD0TN1S9zzTJkEutMIWWYt0/4w==}
+ engines: {node: '>=16.0.0'}
+
idb-keyval@6.3.0:
resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==}
+ ieee754@1.2.1:
+ resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
+
+ iron-webcrypto@1.2.1:
+ resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
+
isows@1.0.7:
resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==}
peerDependencies:
@@ -897,6 +1215,13 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
+ jwt-decode@4.0.0:
+ resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
+ engines: {node: '>=18'}
+
+ keyvaluestorage-interface@1.0.0:
+ resolution: {integrity: sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==}
+
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@@ -911,6 +1236,10 @@ packages:
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+ lru-cache@11.5.2:
+ resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
+ engines: {node: 20 || >=22}
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -928,6 +1257,9 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ multiformats@9.9.0:
+ resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==}
+
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -936,10 +1268,36 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ node-fetch-native@1.6.7:
+ resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
+
+ node-fetch@2.7.0:
+ resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
+ engines: {node: 4.x || >=6.0.0}
+ peerDependencies:
+ encoding: ^0.1.0
+ peerDependenciesMeta:
+ encoding:
+ optional: true
+
+ node-mock-http@1.0.5:
+ resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==}
+
+ normalize-path@3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
+ ofetch@1.5.1:
+ resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==}
+
+ on-exit-leak-free@2.1.2:
+ resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
+ engines: {node: '>=14.0.0'}
+
ox@0.14.30:
resolution: {integrity: sha512-LI11uu+8iiM1B3CLckgd++YF1a0A2k5wDoM9ZeQMiL21BOzQs6L//BLS6hb1HSEKCyycdDIQLsVQx9MjpcC0hA==}
peerDependencies:
@@ -956,6 +1314,14 @@ packages:
typescript:
optional: true
+ ox@0.9.3:
+ resolution: {integrity: sha512-KzyJP+fPV4uhuuqrTZyok4DC7vFzi7HLUFiUNEmpbyh59htKWkOC98IONC1zgXJPbHAhQgqs6B0Z6StCGhmQvg==}
+ peerDependencies:
+ typescript: '>=5.4.0'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
@@ -969,10 +1335,24 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ engines: {node: '>=8.6'}
+
picomatch@4.0.5:
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
engines: {node: '>=12'}
+ pino-abstract-transport@2.0.0:
+ resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
+
+ pino-std-serializers@7.1.0:
+ resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==}
+
+ pino@10.0.0:
+ resolution: {integrity: sha512-eI9pKwWEix40kfvSzqEP6ldqOoBIN7dwD/o91TY5z8vQI12sAffpR/pOqAD1IVVwIVHDpHjkq0joBPdJD0rafA==}
+ hasBin: true
+
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
@@ -1034,10 +1414,37 @@ packages:
resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==}
engines: {node: ^10 || ^12 || >=14}
+ process-warning@5.1.0:
+ resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
+
+ pvtsutils@1.3.6:
+ resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
+
+ pvutils@1.2.0:
+ resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==}
+ engines: {node: '>=16.0.0'}
+
+ quick-format-unescaped@4.0.4:
+ resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
+
+ radix3@1.1.2:
+ resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
+
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
+ readdirp@5.1.1:
+ resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==}
+ engines: {node: '>= 20.19.0'}
+
+ real-require@0.2.0:
+ resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
+ engines: {node: '>= 12.13.0'}
+
+ reflect-metadata@0.2.2:
+ resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
+
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
@@ -1047,9 +1454,22 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ safe-stable-stringify@2.5.0:
+ resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
+ engines: {node: '>=10'}
+
+ sha256-uint8array@0.10.7:
+ resolution: {integrity: sha512-1Q6JQU4tX9NqsDGodej6pkrUVQVNapLZnvkwIhddH/JqzBZF1fSaxSWNY6sziXBE8aEa2twtGkXUrwzGeZCMpQ==}
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+ slow-redact@0.3.2:
+ resolution: {integrity: sha512-MseHyi2+E/hBRqdOi5COy6wZ7j7DxXRz9NkseavNYSvvWC06D8a5cidVZX3tcG5eCW3NIyVU4zT63hw0Q486jw==}
+
+ sonic-boom@4.2.1:
+ resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1058,6 +1478,10 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
+ split2@4.2.0:
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+ engines: {node: '>= 10.x'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -1076,6 +1500,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+ thread-stream@3.2.0:
+ resolution: {integrity: sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==}
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1098,6 +1525,9 @@ packages:
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
engines: {node: '>=14.0.0'}
+ tr46@0.0.3:
+ resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@@ -1105,6 +1535,15 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
+ tslib@1.14.1:
+ resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
+
+ tslib@2.7.0:
+ resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
+
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
tsup@8.5.1:
resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==}
engines: {node: '>=18'}
@@ -1129,6 +1568,10 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
+ tsyringe@4.10.0:
+ resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
+ engines: {node: '>= 6.0.0'}
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1137,9 +1580,84 @@ packages:
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
+ uint8arrays@3.1.1:
+ resolution: {integrity: sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==}
+
+ uncrypto@0.1.3:
+ resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
+
+ undici-types@6.19.8:
+ resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
+
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+ unstorage@1.17.5:
+ resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==}
+ peerDependencies:
+ '@azure/app-configuration': ^1.8.0
+ '@azure/cosmos': ^4.2.0
+ '@azure/data-tables': ^13.3.0
+ '@azure/identity': ^4.6.0
+ '@azure/keyvault-secrets': ^4.9.0
+ '@azure/storage-blob': ^12.26.0
+ '@capacitor/preferences': ^6 || ^7 || ^8
+ '@deno/kv': '>=0.9.0'
+ '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0
+ '@planetscale/database': ^1.19.0
+ '@upstash/redis': ^1.34.3
+ '@vercel/blob': '>=0.27.1'
+ '@vercel/functions': ^2.2.12 || ^3.0.0
+ '@vercel/kv': ^1 || ^2 || ^3
+ aws4fetch: ^1.0.20
+ db0: '>=0.2.1'
+ idb-keyval: ^6.2.1
+ ioredis: ^5.4.2
+ uploadthing: ^7.4.4
+ peerDependenciesMeta:
+ '@azure/app-configuration':
+ optional: true
+ '@azure/cosmos':
+ optional: true
+ '@azure/data-tables':
+ optional: true
+ '@azure/identity':
+ optional: true
+ '@azure/keyvault-secrets':
+ optional: true
+ '@azure/storage-blob':
+ optional: true
+ '@capacitor/preferences':
+ optional: true
+ '@deno/kv':
+ optional: true
+ '@netlify/blobs':
+ optional: true
+ '@planetscale/database':
+ optional: true
+ '@upstash/redis':
+ optional: true
+ '@vercel/blob':
+ optional: true
+ '@vercel/functions':
+ optional: true
+ '@vercel/kv':
+ optional: true
+ aws4fetch:
+ optional: true
+ db0:
+ optional: true
+ idb-keyval:
+ optional: true
+ ioredis:
+ optional: true
+ uploadthing:
+ optional: true
+
+ uuid@11.1.1:
+ resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
+ hasBin: true
+
viem@2.54.6:
resolution: {integrity: sha512-OfybECKJYVmhiNqz+SHhed+O2h6niQ+0Wjg9J0b4bV+/QrvLgjxhfKO7hZqsuK1YtZ/0BErBKy708Zp+cU5T0Q==}
peerDependencies:
@@ -1209,11 +1727,29 @@ packages:
jsdom:
optional: true
+ webidl-conversions@3.0.1:
+ resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+
+ whatwg-url@5.0.0:
+ resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
+
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
+ ws@7.5.13:
+ resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==}
+ engines: {node: '>=8.3.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: ^5.0.2
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
@@ -1551,6 +2087,24 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
+ '@hpke/chacha20poly1305@1.8.0':
+ dependencies:
+ '@hpke/common': 1.10.1
+
+ '@hpke/common@1.10.1': {}
+
+ '@hpke/core@1.9.0':
+ dependencies:
+ '@hpke/common': 1.10.1
+
+ '@hpke/dhkem-x25519@1.8.0':
+ dependencies:
+ '@hpke/common': 1.10.1
+
+ '@hpke/dhkem-x448@1.8.0':
+ dependencies:
+ '@hpke/common': 1.10.1
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1565,14 +2119,132 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@msgpack/msgpack@3.1.3': {}
+
'@noble/ciphers@1.3.0': {}
+ '@noble/curves@1.2.0':
+ dependencies:
+ '@noble/hashes': 1.3.2
+
+ '@noble/curves@1.8.0':
+ dependencies:
+ '@noble/hashes': 1.7.0
+
+ '@noble/curves@1.9.0':
+ dependencies:
+ '@noble/hashes': 1.8.0
+
'@noble/curves@1.9.1':
dependencies:
'@noble/hashes': 1.8.0
+ '@noble/curves@1.9.7':
+ dependencies:
+ '@noble/hashes': 1.8.0
+
+ '@noble/hashes@1.3.2': {}
+
+ '@noble/hashes@1.7.0': {}
+
'@noble/hashes@1.8.0': {}
+ '@openzeppelin/contracts@5.6.1': {}
+
+ '@peculiar/asn1-cms@2.8.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/asn1-x509': 2.8.0
+ '@peculiar/asn1-x509-attr': 2.8.0
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/asn1-csr@2.8.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/asn1-x509': 2.8.0
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/asn1-ecc@2.8.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/asn1-x509': 2.8.0
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/asn1-pfx@2.8.0':
+ dependencies:
+ '@peculiar/asn1-cms': 2.8.0
+ '@peculiar/asn1-pkcs8': 2.8.0
+ '@peculiar/asn1-rsa': 2.8.0
+ '@peculiar/asn1-schema': 2.8.0
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/asn1-pkcs8@2.8.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/asn1-x509': 2.8.0
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/asn1-pkcs9@2.8.0':
+ dependencies:
+ '@peculiar/asn1-cms': 2.8.0
+ '@peculiar/asn1-pfx': 2.8.0
+ '@peculiar/asn1-pkcs8': 2.8.0
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/asn1-x509': 2.8.0
+ '@peculiar/asn1-x509-attr': 2.8.0
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/asn1-rsa@2.8.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/asn1-x509': 2.8.0
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/asn1-schema@2.8.0':
+ dependencies:
+ '@peculiar/utils': 2.0.3
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/asn1-x509-attr@2.8.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/asn1-x509': 2.8.0
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/asn1-x509@2.8.0':
+ dependencies:
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/utils': 2.0.3
+ asn1js: 3.0.10
+ tslib: 2.8.1
+
+ '@peculiar/utils@2.0.3':
+ dependencies:
+ tslib: 2.8.1
+
+ '@peculiar/x509@1.12.3':
+ dependencies:
+ '@peculiar/asn1-cms': 2.8.0
+ '@peculiar/asn1-csr': 2.8.0
+ '@peculiar/asn1-ecc': 2.8.0
+ '@peculiar/asn1-pkcs9': 2.8.0
+ '@peculiar/asn1-rsa': 2.8.0
+ '@peculiar/asn1-schema': 2.8.0
+ '@peculiar/asn1-x509': 2.8.0
+ pvtsutils: 1.3.6
+ reflect-metadata: 0.2.2
+ tslib: 2.8.1
+ tsyringe: 4.10.0
+
'@rollup/rollup-android-arm-eabi@4.62.2':
optional: true
@@ -1661,12 +2333,190 @@ snapshots:
'@noble/hashes': 1.8.0
'@scure/base': 1.2.6
+ '@turnkey/api-key-stamper@0.6.10':
+ dependencies:
+ '@noble/curves': 1.9.1
+ '@turnkey/crypto': 2.11.1
+ '@turnkey/encoding': 0.6.0
+ sha256-uint8array: 0.10.7
+
+ '@turnkey/core@2.5.0(typescript@5.9.3)(zod@4.4.3)':
+ dependencies:
+ '@turnkey/api-key-stamper': 0.6.10
+ '@turnkey/crypto': 2.11.1
+ '@turnkey/encoding': 0.6.0
+ '@turnkey/http': 6.1.0
+ '@turnkey/sdk-types': 1.4.0
+ '@turnkey/webauthn-stamper': 0.6.0
+ '@wallet-standard/app': 1.1.1
+ '@wallet-standard/base': 1.1.1
+ '@walletconnect/sign-client': 2.23.10(typescript@5.9.3)(zod@4.4.3)
+ '@walletconnect/types': 2.23.10
+ cross-fetch: 3.2.0
+ ethers: 6.17.0
+ jwt-decode: 4.0.0
+ uuid: 11.1.1
+ viem: 2.54.6(typescript@5.9.3)(zod@4.4.3)
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - bufferutil
+ - db0
+ - encoding
+ - ioredis
+ - typescript
+ - uploadthing
+ - utf-8-validate
+ - zod
+
+ '@turnkey/crypto@2.11.1':
+ dependencies:
+ '@noble/ciphers': 1.3.0
+ '@noble/curves': 1.9.0
+ '@noble/hashes': 1.8.0
+ '@peculiar/x509': 1.12.3
+ '@turnkey/encoding': 0.6.0
+ '@turnkey/sdk-types': 1.4.0
+ borsh: 2.0.0
+ cbor-js: 0.1.0
+
+ '@turnkey/encoding@0.6.0':
+ dependencies:
+ bs58: 6.0.0
+ bs58check: 4.0.0
+
+ '@turnkey/http@6.1.0':
+ dependencies:
+ '@turnkey/api-key-stamper': 0.6.10
+ '@turnkey/encoding': 0.6.0
+ '@turnkey/webauthn-stamper': 0.6.0
+ cross-fetch: 3.2.0
+ transitivePeerDependencies:
+ - encoding
+
+ '@turnkey/iframe-stamper@2.11.1': {}
+
+ '@turnkey/indexed-db-stamper@1.3.4':
+ dependencies:
+ '@turnkey/encoding': 0.6.0
+ '@turnkey/sdk-types': 1.4.0
+
+ '@turnkey/sdk-browser@8.1.0(typescript@5.9.3)(zod@4.4.3)':
+ dependencies:
+ '@turnkey/api-key-stamper': 0.6.10
+ '@turnkey/crypto': 2.11.1
+ '@turnkey/encoding': 0.6.0
+ '@turnkey/http': 6.1.0
+ '@turnkey/iframe-stamper': 2.11.1
+ '@turnkey/indexed-db-stamper': 1.3.4
+ '@turnkey/sdk-types': 1.4.0
+ '@turnkey/wallet-stamper': 1.1.22(typescript@5.9.3)(zod@4.4.3)
+ '@turnkey/webauthn-stamper': 0.6.0
+ buffer: 6.0.3
+ cross-fetch: 3.2.0
+ hpke-js: 1.8.0
+ transitivePeerDependencies:
+ - bufferutil
+ - encoding
+ - typescript
+ - utf-8-validate
+ - zod
+
+ '@turnkey/sdk-server@8.1.0(typescript@5.9.3)(zod@4.4.3)':
+ dependencies:
+ '@turnkey/api-key-stamper': 0.6.10
+ '@turnkey/http': 6.1.0
+ '@turnkey/sdk-types': 1.4.0
+ '@turnkey/wallet-stamper': 1.1.22(typescript@5.9.3)(zod@4.4.3)
+ buffer: 6.0.3
+ cross-fetch: 3.2.0
+ transitivePeerDependencies:
+ - bufferutil
+ - encoding
+ - typescript
+ - utf-8-validate
+ - zod
+
+ '@turnkey/sdk-types@1.4.0': {}
+
+ '@turnkey/viem@0.14.34(typescript@5.9.3)(viem@2.54.6(typescript@5.9.3)(zod@4.4.3))(zod@4.4.3)':
+ dependencies:
+ '@noble/curves': 1.8.0
+ '@openzeppelin/contracts': 5.6.1
+ '@turnkey/api-key-stamper': 0.6.10
+ '@turnkey/core': 2.5.0(typescript@5.9.3)(zod@4.4.3)
+ '@turnkey/http': 6.1.0
+ '@turnkey/sdk-browser': 8.1.0(typescript@5.9.3)(zod@4.4.3)
+ '@turnkey/sdk-server': 8.1.0(typescript@5.9.3)(zod@4.4.3)
+ cross-fetch: 4.1.0
+ viem: 2.54.6(typescript@5.9.3)(zod@4.4.3)
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@react-native-async-storage/async-storage'
+ - '@turnkey/react-native-passkey-stamper'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - bufferutil
+ - db0
+ - encoding
+ - ioredis
+ - react-native-keychain
+ - typescript
+ - uploadthing
+ - utf-8-validate
+ - zod
+
+ '@turnkey/wallet-stamper@1.1.22(typescript@5.9.3)(zod@4.4.3)':
+ dependencies:
+ '@turnkey/crypto': 2.11.1
+ '@turnkey/encoding': 0.6.0
+ optionalDependencies:
+ viem: 2.54.6(typescript@5.9.3)(zod@4.4.3)
+ transitivePeerDependencies:
+ - bufferutil
+ - typescript
+ - utf-8-validate
+ - zod
+
+ '@turnkey/webauthn-stamper@0.6.0':
+ dependencies:
+ sha256-uint8array: 0.10.7
+
'@types/estree@1.0.9': {}
'@types/node@20.19.43':
dependencies:
undici-types: 6.21.0
+ '@types/node@22.7.5':
+ dependencies:
+ undici-types: 6.19.8
+
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
@@ -1721,6 +2571,264 @@ snapshots:
- react
- use-sync-external-store
+ '@wallet-standard/app@1.1.1':
+ dependencies:
+ '@wallet-standard/base': 1.1.1
+
+ '@wallet-standard/base@1.1.1': {}
+
+ '@walletconnect/core@2.23.10(typescript@5.9.3)(zod@4.4.3)':
+ dependencies:
+ '@walletconnect/heartbeat': 1.2.2
+ '@walletconnect/jsonrpc-provider': 1.0.14
+ '@walletconnect/jsonrpc-types': 1.0.4
+ '@walletconnect/jsonrpc-utils': 1.0.8
+ '@walletconnect/jsonrpc-ws-connection': 1.0.16
+ '@walletconnect/keyvaluestorage': 1.1.1
+ '@walletconnect/logger': 3.0.2
+ '@walletconnect/relay-api': 1.0.11
+ '@walletconnect/relay-auth': 1.1.0
+ '@walletconnect/safe-json': 1.0.2
+ '@walletconnect/time': 1.0.2
+ '@walletconnect/types': 2.23.10
+ '@walletconnect/utils': 2.23.10(typescript@5.9.3)(zod@4.4.3)
+ '@walletconnect/window-getters': 1.0.1
+ es-toolkit: 1.45.1
+ events: 3.3.0
+ uint8arrays: 3.1.1
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@react-native-async-storage/async-storage'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - bufferutil
+ - db0
+ - ioredis
+ - typescript
+ - uploadthing
+ - utf-8-validate
+ - zod
+
+ '@walletconnect/environment@1.0.1':
+ dependencies:
+ tslib: 1.14.1
+
+ '@walletconnect/events@1.0.1':
+ dependencies:
+ keyvaluestorage-interface: 1.0.0
+ tslib: 1.14.1
+
+ '@walletconnect/heartbeat@1.2.2':
+ dependencies:
+ '@walletconnect/events': 1.0.1
+ '@walletconnect/time': 1.0.2
+ events: 3.3.0
+
+ '@walletconnect/jsonrpc-provider@1.0.14':
+ dependencies:
+ '@walletconnect/jsonrpc-utils': 1.0.8
+ '@walletconnect/safe-json': 1.0.2
+ events: 3.3.0
+
+ '@walletconnect/jsonrpc-types@1.0.4':
+ dependencies:
+ events: 3.3.0
+ keyvaluestorage-interface: 1.0.0
+
+ '@walletconnect/jsonrpc-utils@1.0.8':
+ dependencies:
+ '@walletconnect/environment': 1.0.1
+ '@walletconnect/jsonrpc-types': 1.0.4
+ tslib: 1.14.1
+
+ '@walletconnect/jsonrpc-ws-connection@1.0.16':
+ dependencies:
+ '@walletconnect/jsonrpc-utils': 1.0.8
+ '@walletconnect/safe-json': 1.0.2
+ events: 3.3.0
+ ws: 7.5.13
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
+ '@walletconnect/keyvaluestorage@1.1.1':
+ dependencies:
+ '@walletconnect/safe-json': 1.0.2
+ idb-keyval: 6.3.0
+ unstorage: 1.17.5(idb-keyval@6.3.0)
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - db0
+ - ioredis
+ - uploadthing
+
+ '@walletconnect/logger@3.0.2':
+ dependencies:
+ '@walletconnect/safe-json': 1.0.2
+ pino: 10.0.0
+
+ '@walletconnect/relay-api@1.0.11':
+ dependencies:
+ '@walletconnect/jsonrpc-types': 1.0.4
+
+ '@walletconnect/relay-auth@1.1.0':
+ dependencies:
+ '@noble/curves': 1.8.0
+ '@noble/hashes': 1.7.0
+ '@walletconnect/safe-json': 1.0.2
+ '@walletconnect/time': 1.0.2
+ uint8arrays: 3.1.1
+
+ '@walletconnect/safe-json@1.0.2':
+ dependencies:
+ tslib: 1.14.1
+
+ '@walletconnect/sign-client@2.23.10(typescript@5.9.3)(zod@4.4.3)':
+ dependencies:
+ '@walletconnect/core': 2.23.10(typescript@5.9.3)(zod@4.4.3)
+ '@walletconnect/jsonrpc-utils': 1.0.8
+ '@walletconnect/logger': 3.0.2
+ '@walletconnect/time': 1.0.2
+ '@walletconnect/types': 2.23.10
+ '@walletconnect/utils': 2.23.10(typescript@5.9.3)(zod@4.4.3)
+ events: 3.3.0
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@react-native-async-storage/async-storage'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - bufferutil
+ - db0
+ - ioredis
+ - typescript
+ - uploadthing
+ - utf-8-validate
+ - zod
+
+ '@walletconnect/time@1.0.2':
+ dependencies:
+ tslib: 1.14.1
+
+ '@walletconnect/types@2.23.10':
+ dependencies:
+ '@walletconnect/events': 1.0.1
+ '@walletconnect/heartbeat': 1.2.2
+ '@walletconnect/jsonrpc-types': 1.0.4
+ '@walletconnect/keyvaluestorage': 1.1.1
+ '@walletconnect/logger': 3.0.2
+ events: 3.3.0
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@react-native-async-storage/async-storage'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - db0
+ - ioredis
+ - uploadthing
+
+ '@walletconnect/utils@2.23.10(typescript@5.9.3)(zod@4.4.3)':
+ dependencies:
+ '@msgpack/msgpack': 3.1.3
+ '@noble/ciphers': 1.3.0
+ '@noble/curves': 1.9.7
+ '@noble/hashes': 1.8.0
+ '@scure/base': 1.2.6
+ '@walletconnect/jsonrpc-utils': 1.0.8
+ '@walletconnect/keyvaluestorage': 1.1.1
+ '@walletconnect/logger': 3.0.2
+ '@walletconnect/relay-api': 1.0.11
+ '@walletconnect/relay-auth': 1.1.0
+ '@walletconnect/safe-json': 1.0.2
+ '@walletconnect/time': 1.0.2
+ '@walletconnect/types': 2.23.10
+ '@walletconnect/window-getters': 1.0.1
+ '@walletconnect/window-metadata': 1.0.1
+ blakejs: 1.2.1
+ detect-browser: 5.3.0
+ ox: 0.9.3(typescript@5.9.3)(zod@4.4.3)
+ uint8arrays: 3.1.1
+ transitivePeerDependencies:
+ - '@azure/app-configuration'
+ - '@azure/cosmos'
+ - '@azure/data-tables'
+ - '@azure/identity'
+ - '@azure/keyvault-secrets'
+ - '@azure/storage-blob'
+ - '@capacitor/preferences'
+ - '@deno/kv'
+ - '@netlify/blobs'
+ - '@planetscale/database'
+ - '@react-native-async-storage/async-storage'
+ - '@upstash/redis'
+ - '@vercel/blob'
+ - '@vercel/functions'
+ - '@vercel/kv'
+ - aws4fetch
+ - db0
+ - ioredis
+ - typescript
+ - uploadthing
+ - zod
+
+ '@walletconnect/window-getters@1.0.1':
+ dependencies:
+ tslib: 1.14.1
+
+ '@walletconnect/window-metadata@1.0.1':
+ dependencies:
+ '@walletconnect/window-getters': 1.0.1
+ tslib: 1.14.1
+
abitype@1.2.3(typescript@5.9.3)(zod@4.4.3):
optionalDependencies:
typescript: 5.9.3
@@ -1728,10 +2836,47 @@ snapshots:
acorn@8.17.0: {}
+ aes-js@4.0.0-beta.5: {}
+
any-promise@1.3.0: {}
+ anymatch@3.1.3:
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.2
+
+ asn1js@3.0.10:
+ dependencies:
+ pvtsutils: 1.3.6
+ pvutils: 1.2.0
+ tslib: 2.8.1
+
assertion-error@2.0.1: {}
+ atomic-sleep@1.0.0: {}
+
+ base-x@5.0.1: {}
+
+ base64-js@1.5.1: {}
+
+ blakejs@1.2.1: {}
+
+ borsh@2.0.0: {}
+
+ bs58@6.0.0:
+ dependencies:
+ base-x: 5.0.1
+
+ bs58check@4.0.0:
+ dependencies:
+ '@noble/hashes': 1.8.0
+ bs58: 6.0.0
+
+ buffer@6.0.3:
+ dependencies:
+ base64-js: 1.5.1
+ ieee754: 1.2.1
+
bundle-require@5.1.0(esbuild@0.27.7):
dependencies:
esbuild: 0.27.7
@@ -1739,6 +2884,8 @@ snapshots:
cac@6.7.14: {}
+ cbor-js@0.1.0: {}
+
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -1753,22 +2900,52 @@ snapshots:
dependencies:
readdirp: 4.1.2
+ chokidar@5.0.0:
+ dependencies:
+ readdirp: 5.1.1
+
commander@4.1.1: {}
confbox@0.1.8: {}
consola@3.4.2: {}
+ cookie-es@1.2.3: {}
+
+ cross-fetch@3.2.0:
+ dependencies:
+ node-fetch: 2.7.0
+ transitivePeerDependencies:
+ - encoding
+
+ cross-fetch@4.1.0:
+ dependencies:
+ node-fetch: 2.7.0
+ transitivePeerDependencies:
+ - encoding
+
+ crossws@0.3.5:
+ dependencies:
+ uncrypto: 0.1.3
+
debug@4.4.3:
dependencies:
ms: 2.1.3
deep-eql@5.0.2: {}
+ defu@6.1.7: {}
+
+ destr@2.0.5: {}
+
+ detect-browser@5.3.0: {}
+
dotenv@16.6.1: {}
es-module-lexer@1.7.0: {}
+ es-toolkit@1.45.1: {}
+
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -1857,8 +3034,23 @@ snapshots:
dependencies:
'@types/estree': 1.0.9
+ ethers@6.17.0:
+ dependencies:
+ '@adraffy/ens-normalize': 1.11.1
+ '@noble/curves': 1.2.0
+ '@noble/hashes': 1.3.2
+ '@types/node': 22.7.5
+ aes-js: 4.0.0-beta.5
+ tslib: 2.7.0
+ ws: 8.21.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+
eventemitter3@5.0.1: {}
+ events@3.3.0: {}
+
expect-type@1.4.0: {}
fdir@6.5.0(picomatch@4.0.5):
@@ -1874,16 +3066,44 @@ snapshots:
fsevents@2.3.3:
optional: true
+ h3@1.15.11:
+ dependencies:
+ cookie-es: 1.2.3
+ crossws: 0.3.5
+ defu: 6.1.7
+ destr: 2.0.5
+ iron-webcrypto: 1.2.1
+ node-mock-http: 1.0.5
+ radix3: 1.1.2
+ ufo: 1.6.4
+ uncrypto: 0.1.3
+
hono@4.12.29: {}
+ hpke-js@1.8.0:
+ dependencies:
+ '@hpke/chacha20poly1305': 1.8.0
+ '@hpke/common': 1.10.1
+ '@hpke/core': 1.9.0
+ '@hpke/dhkem-x25519': 1.8.0
+ '@hpke/dhkem-x448': 1.8.0
+
idb-keyval@6.3.0: {}
+ ieee754@1.2.1: {}
+
+ iron-webcrypto@1.2.1: {}
+
isows@1.0.7(ws@8.21.0):
dependencies:
ws: 8.21.0
joycon@3.1.1: {}
+ jwt-decode@4.0.0: {}
+
+ keyvaluestorage-interface@1.0.0: {}
+
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@@ -1892,6 +3112,8 @@ snapshots:
loupe@3.2.1: {}
+ lru-cache@11.5.2: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1909,6 +3131,8 @@ snapshots:
ms@2.1.3: {}
+ multiformats@9.9.0: {}
+
mz@2.7.0:
dependencies:
any-promise: 1.3.0
@@ -1917,8 +3141,26 @@ snapshots:
nanoid@3.3.15: {}
+ node-fetch-native@1.6.7: {}
+
+ node-fetch@2.7.0:
+ dependencies:
+ whatwg-url: 5.0.0
+
+ node-mock-http@1.0.5: {}
+
+ normalize-path@3.0.0: {}
+
object-assign@4.1.1: {}
+ ofetch@1.5.1:
+ dependencies:
+ destr: 2.0.5
+ node-fetch-native: 1.6.7
+ ufo: 1.6.4
+
+ on-exit-leak-free@2.1.2: {}
+
ox@0.14.30(typescript@5.9.3)(zod@4.4.3):
dependencies:
'@adraffy/ens-normalize': 1.11.1
@@ -1949,6 +3191,21 @@ snapshots:
transitivePeerDependencies:
- zod
+ ox@0.9.3(typescript@5.9.3)(zod@4.4.3):
+ dependencies:
+ '@adraffy/ens-normalize': 1.11.1
+ '@noble/ciphers': 1.3.0
+ '@noble/curves': 1.9.1
+ '@noble/hashes': 1.8.0
+ '@scure/bip32': 1.7.0
+ '@scure/bip39': 1.6.0
+ abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3)
+ eventemitter3: 5.0.1
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - zod
+
pathe@1.1.2: {}
pathe@2.0.3: {}
@@ -1957,8 +3214,30 @@ snapshots:
picocolors@1.1.1: {}
+ picomatch@2.3.2: {}
+
picomatch@4.0.5: {}
+ pino-abstract-transport@2.0.0:
+ dependencies:
+ split2: 4.2.0
+
+ pino-std-serializers@7.1.0: {}
+
+ pino@10.0.0:
+ dependencies:
+ atomic-sleep: 1.0.0
+ on-exit-leak-free: 2.1.2
+ pino-abstract-transport: 2.0.0
+ pino-std-serializers: 7.1.0
+ process-warning: 5.1.0
+ quick-format-unescaped: 4.0.4
+ real-require: 0.2.0
+ safe-stable-stringify: 2.5.0
+ slow-redact: 0.3.2
+ sonic-boom: 4.2.1
+ thread-stream: 3.2.0
+
pirates@4.0.7: {}
pkg-types@1.3.1:
@@ -1997,8 +3276,26 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ process-warning@5.1.0: {}
+
+ pvtsutils@1.3.6:
+ dependencies:
+ tslib: 2.8.1
+
+ pvutils@1.2.0: {}
+
+ quick-format-unescaped@4.0.4: {}
+
+ radix3@1.1.2: {}
+
readdirp@4.1.2: {}
+ readdirp@5.1.1: {}
+
+ real-require@0.2.0: {}
+
+ reflect-metadata@0.2.2: {}
+
resolve-from@5.0.0: {}
rollup@4.62.2:
@@ -2032,12 +3329,24 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.62.2
fsevents: 2.3.3
+ safe-stable-stringify@2.5.0: {}
+
+ sha256-uint8array@0.10.7: {}
+
siginfo@2.0.0: {}
+ slow-redact@0.3.2: {}
+
+ sonic-boom@4.2.1:
+ dependencies:
+ atomic-sleep: 1.0.0
+
source-map-js@1.2.1: {}
source-map@0.7.6: {}
+ split2@4.2.0: {}
+
stackback@0.0.2: {}
std-env@3.10.0: {}
@@ -2060,6 +3369,10 @@ snapshots:
dependencies:
any-promise: 1.3.0
+ thread-stream@3.2.0:
+ dependencies:
+ real-require: 0.2.0
+
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -2075,10 +3388,18 @@ snapshots:
tinyspy@3.0.2: {}
+ tr46@0.0.3: {}
+
tree-kill@1.2.2: {}
ts-interface-checker@0.1.13: {}
+ tslib@1.14.1: {}
+
+ tslib@2.7.0: {}
+
+ tslib@2.8.1: {}
+
tsup@8.5.1(postcss@8.5.16)(tsx@4.23.0)(typescript@5.9.3):
dependencies:
bundle-require: 5.1.0(esbuild@0.27.7)
@@ -2113,12 +3434,39 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ tsyringe@4.10.0:
+ dependencies:
+ tslib: 1.14.1
+
typescript@5.9.3: {}
ufo@1.6.4: {}
+ uint8arrays@3.1.1:
+ dependencies:
+ multiformats: 9.9.0
+
+ uncrypto@0.1.3: {}
+
+ undici-types@6.19.8: {}
+
undici-types@6.21.0: {}
+ unstorage@1.17.5(idb-keyval@6.3.0):
+ dependencies:
+ anymatch: 3.1.3
+ chokidar: 5.0.0
+ destr: 2.0.5
+ h3: 1.15.11
+ lru-cache: 11.5.2
+ node-fetch-native: 1.6.7
+ ofetch: 1.5.1
+ ufo: 1.6.4
+ optionalDependencies:
+ idb-keyval: 6.3.0
+
+ uuid@11.1.1: {}
+
viem@2.54.6(typescript@5.9.3)(zod@4.4.3):
dependencies:
'@noble/curves': 1.9.1
@@ -2198,11 +3546,20 @@ snapshots:
- supports-color
- terser
+ webidl-conversions@3.0.1: {}
+
+ whatwg-url@5.0.0:
+ dependencies:
+ tr46: 0.0.3
+ webidl-conversions: 3.0.1
+
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
+ ws@7.5.13: {}
+
ws@8.21.0: {}
zod@4.4.3: {}
diff --git a/typescript/src/erc8183/config.ts b/typescript/src/erc8183/config.ts
index a225f34..d7e88e2 100644
--- a/typescript/src/erc8183/config.ts
+++ b/typescript/src/erc8183/config.ts
@@ -34,7 +34,10 @@
* storage returns file:// scheme)
*
* Global env vars: NETWORK / PRIVATE_KEY / WALLET_PASSWORD / WALLET_ADDRESS
- * / WALLET_KIND.
+ * / WALLET_KIND. With `WALLET_KIND=turnkey` the wallet is built from the
+ * `TURNKEY_*` env vars (`TURNKEY_API_PUBLIC_KEY`, `TURNKEY_API_PRIVATE_KEY`,
+ * `TURNKEY_ORG_ID`, `TURNKEY_SIGN_WITH`, optional `TURNKEY_API_BASE_URL`)
+ * and WALLET_PASSWORD is not required.
*
* Payment token address is NOT configurable — it is immutable on the
* Commerce kernel and fetched at runtime via `ERC8183Client.paymentToken`.
@@ -44,11 +47,14 @@
* TypeScript port yet).
*/
+import { getAddress } from "viem";
import type { NetworkConfig } from "../config.js";
import { getEnv } from "../core/envUtil.js";
import { LocalStorageProvider } from "../storage/localStorageProvider.js";
import type { StorageProvider } from "../storage/storageProvider.js";
+import { WalletIdentityMismatch } from "../wallets/errors.js";
import { EVMWalletProvider } from "../wallets/evmWalletProvider.js";
+import { TurnkeyWalletProvider } from "../wallets/turnkey/provider.js";
import {
TWAKProvider,
TWAK_CHAIN_FOR_NETWORK,
@@ -72,8 +78,10 @@ export interface ERC8183ConfigOpts {
walletPassword?: string;
/** Select a specific wallet from the keystore directory (by address). */
walletAddress?: string;
- /** Select a non-EVM provider (out of scope; `"evm"` / `""` are the only
- * accepted values — anything else throws). */
+ /** Select the wallet backend: `"evm"` / `""` (keystore), `"twak"`
+ * (self-broadcasting CLI) or `"turnkey"` (remote enclave signer, built
+ * from the `TURNKEY_*` env vars) — anything else throws. Advisory
+ * metadata only when `walletProvider` is supplied directly. */
walletKind?: string;
/** Off-chain storage for deliverables. */
storage?: StorageProvider | null;
@@ -139,6 +147,22 @@ export class ERC8183Config {
chain,
...(this.walletAddress ? { expectedAddress: this.walletAddress } : {}),
});
+ } else if (kind === "turnkey" && !walletProvider) {
+ // 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.
+ walletProvider = TurnkeyWalletProvider.fromEnv({
+ expectedChainId: this.effectiveChainId,
+ });
+ if (
+ this.walletAddress &&
+ getAddress(this.walletAddress) !== walletProvider.address
+ ) {
+ throw new WalletIdentityMismatch({
+ expected: getAddress(this.walletAddress),
+ actual: walletProvider.address,
+ });
+ }
} else if (kind && kind !== "evm" && !walletProvider) {
throw new Error(`Unknown wallet kind: ${this.walletKind}`);
}
@@ -257,8 +281,8 @@ export class ERC8183Config {
const walletKind = getEnv("WALLET_KIND") ?? "";
// The WALLET_PASSWORD requirement is an EVM-kind concern: a non-EVM
- // kind (twak) owns its custody end-to-end, so no password is needed
- // here — the constructor dispatches it to TWAKProvider.
+ // kind (twak, turnkey) owns its custody end-to-end, so no password is
+ // needed here — the constructor dispatches to the matching provider.
const kind = walletKind.trim().toLowerCase();
if (kind === "" || kind === "evm") {
if (!walletPassword) {
diff --git a/typescript/src/index.ts b/typescript/src/index.ts
index 054800e..37f5133 100644
--- a/typescript/src/index.ts
+++ b/typescript/src/index.ts
@@ -77,6 +77,9 @@ export { AltanaWalletProvider } from "./wallets/altana/index.js";
// Self-broadcasting TWAK (Trust Wallet Agent Kit CLI) wallet — twak is an
// npm CLI; install @trustwallet/cli and the provider finds the local bin.
export { TWAKProvider } from "./wallets/twak/index.js";
+// Pure-signer Turnkey wallet (remote signing, keys in AWS Nitro enclaves).
+// The backing @turnkey/* peers are optional and loaded lazily on first use.
+export { TurnkeyWalletProvider } from "./wallets/turnkey/index.js";
// ERC-8183 — only essential public API
export { ERC8183Client } from "./erc8183/client.js";
diff --git a/typescript/src/wallets/index.ts b/typescript/src/wallets/index.ts
index 350e51a..b2f609a 100644
--- a/typescript/src/wallets/index.ts
+++ b/typescript/src/wallets/index.ts
@@ -3,6 +3,7 @@ export * from "./capabilities.js";
export * from "./errors.js";
export * from "./evmWalletProvider.js";
export * from "./intents.js";
+export * from "./turnkey/index.js";
export * from "./twak/index.js";
export * from "./keystore.js";
export * from "./localExecutor.js";
diff --git a/typescript/src/wallets/turnkey/index.ts b/typescript/src/wallets/turnkey/index.ts
new file mode 100644
index 0000000..64426e7
--- /dev/null
+++ b/typescript/src/wallets/turnkey/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Turnkey wallet provider surface.
+ *
+ * Note the deliberate omission: the internal SDK-module mirrors
+ * (`TurnkeySdkModules` and friends in `./types.js`) stay out of the barrel —
+ * they type the lazy `@turnkey/*` boundary, not the public API.
+ */
+
+export {
+ TURNKEY_API_BASE_URL_DEFAULT,
+ TurnkeyWalletProvider,
+} from "./provider.js";
+export type {
+ TurnkeyFromEnvOptions,
+ TurnkeyWalletProviderOptions,
+} from "./provider.js";
+export {
+ TURNKEY_SDK_SERVER_PACKAGE,
+ TURNKEY_VIEM_PACKAGE,
+ setTurnkeySdkImporter,
+} from "./sdkLoader.js";
+export type { TurnkeyPackageName, TurnkeySdkImporter } from "./sdkLoader.js";
diff --git a/typescript/src/wallets/turnkey/provider.ts b/typescript/src/wallets/turnkey/provider.ts
new file mode 100644
index 0000000..9f19de0
--- /dev/null
+++ b/typescript/src/wallets/turnkey/provider.ts
@@ -0,0 +1,409 @@
+/**
+ * Turnkey Wallet Provider — remote signing, keys in AWS Nitro Enclaves.
+ *
+ * A pure signer over Turnkey's hosted key-management API
+ * (https://docs.turnkey.com): the private key is generated and held inside
+ * Turnkey's enclave and never leaves it; every sign call is an
+ * authenticated HTTPS round-trip stamped by a locally held P-256 API key
+ * pair. No key material ever exists on this machine.
+ *
+ * Operational constraints that shaped this implementation:
+ *
+ * - **Every successful signature is billed** (free tier: 25/month at
+ * 1 request/second; pay-as-you-go $0.10/signature). All client-side
+ * guards (SigningPolicy, chain-id pinning, 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 must use a non-root API user plus an
+ * explicit ALLOW policy; this cannot be detected client-side.
+ * - **Broadcast is not included** (managed broadcast is a paid feature).
+ * The provider only signs; the default `LocalExecutor` broadcasts over
+ * the SDK's own RPC, and the MegaFuel paymaster path works unchanged.
+ * - **EIP-712 domain-stripping trap** (`@turnkey/viem` ≤0.14.34, probe
+ * finding 2026-07-24): when the `types` object passed to the Turnkey
+ * account lacks an explicit `EIP712Domain` entry, viem's
+ * `serializeTypedData` silently serializes the domain as `{}` — the
+ * signature succeeds, is billed, and binds an EMPTY domain (unverifiable
+ * / replayable across domains). {@link TurnkeyWalletProvider.signTypedData}
+ * therefore always injects the full `EIP712Domain` type into the enclave
+ * payload. The injection is idempotent, so it stays safe if upstream
+ * fixes the default.
+ */
+
+import type {
+ LocalAccount,
+ TransactionSerializable,
+ TypedDataDomain,
+} from "viem";
+import {
+ getAddress,
+ getTypesForEIP712Domain,
+ hashMessage,
+ hashTypedData,
+ keccak256,
+ parseSignature,
+ parseTransaction,
+} from "viem";
+import { getEnv } from "../../core/envUtil.js";
+import { check, inferPrimaryType } from "../../signing/checks.js";
+import { SigningPolicy } from "../../signing/policy.js";
+import { CALLS_ARBITRARY, PAYMASTER_SPONSOR } from "../capabilities.js";
+import { WalletIdentityMismatch } from "../errors.js";
+import type {
+ SignableTransaction,
+ SignatureResult,
+ SignedTx,
+} from "../walletProvider.js";
+import { WalletProvider } from "../walletProvider.js";
+import { loadTurnkeySdk } from "./sdkLoader.js";
+
+/** Default Turnkey API host. */
+export const TURNKEY_API_BASE_URL_DEFAULT = "https://api.turnkey.com";
+
+const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
+
+/** Constructor options for {@link TurnkeyWalletProvider}. */
+export interface TurnkeyWalletProviderOptions {
+ /** Turnkey organization id (dashboard → settings). */
+ organizationId: string;
+ /**
+ * The wallet account to sign with — MUST be the account's Ethereum
+ * address (`0x` + 40 hex chars), not a Turnkey wallet id or private-key
+ * id. Constraining to the address keeps {@link TurnkeyWalletProvider.address}
+ * synchronous and avoids a lookup round-trip.
+ */
+ signWith: string;
+ /** P-256 API key public component (dashboard → API keys). */
+ apiPublicKey: string;
+ /** P-256 API key private component. A client credential — never leaves this process. */
+ apiPrivateKey: string;
+ /** API host override (default {@link TURNKEY_API_BASE_URL_DEFAULT}). */
+ apiBaseUrl?: string;
+ /**
+ * When set, {@link TurnkeyWalletProvider.signTransaction} refuses any
+ * transaction whose `chainId` differs — fail-closed BEFORE the billable
+ * API call.
+ */
+ expectedChainId?: number;
+ /**
+ * Policy applied to every {@link TurnkeyWalletProvider.signTypedData}
+ * call, BEFORE the billable API call. Defaults to
+ * {@link SigningPolicy.strictDefault}. This client-side gate is the first
+ * of two layers — Turnkey's server-side policy engine is the second (and
+ * is bypassed entirely for root API users).
+ */
+ signingPolicy?: SigningPolicy;
+}
+
+/** Options accepted by {@link TurnkeyWalletProvider.fromEnv}. */
+export interface TurnkeyFromEnvOptions {
+ expectedChainId?: number;
+ signingPolicy?: SigningPolicy;
+}
+
+/**
+ * Rewrite recognizable Turnkey API failures into actionable errors.
+ *
+ * Detection is intentionally string/shape-based (the SDK error classes live
+ * in the optional peer, which may not be installed at type-check time).
+ * Unrecognized errors pass through untouched.
+ */
+function mapTurnkeyError(op: string, error: unknown): unknown {
+ const message = error instanceof Error ? error.message : String(error);
+ const status = (error as { status?: unknown } | null)?.status;
+ if (/quota/i.test(message)) {
+ return new Error(
+ `Turnkey signature quota exhausted during ${op} (free tier: 25 billed signatures/month; pay-as-you-go $0.10/signature) — check the Turnkey billing dashboard.`,
+ { cause: error },
+ );
+ }
+ if (status === 429 || /rate.?limit/i.test(message)) {
+ return new Error(
+ `Turnkey rate limit hit during ${op} (free tier allows 1 request/second) — pace calls or upgrade the plan.`,
+ { cause: error },
+ );
+ }
+ if (/policy/i.test(message) && /denied|reject/i.test(message)) {
+ return new Error(
+ `Turnkey server-side policy denied ${op} — verify the API user has an explicit ALLOW policy covering this operation (non-root users are default-deny).`,
+ { cause: error },
+ );
+ }
+ return error;
+}
+
+/**
+ * Wallet provider backed by Turnkey's remote enclave signing service.
+ *
+ * A pure signer: implements the three `sign*` methods (capabilities derive
+ * automatically) and inherits the default `LocalExecutor` path, so ERC-8004
+ * / ERC-8183 writes, x402 payments (via `X402Signer`) and MegaFuel
+ * sponsorship all work without Turnkey-specific wiring.
+ *
+ * Construction is cheap and offline — the Turnkey packages load and the
+ * signing account is built lazily on the first sign call.
+ *
+ * ```ts
+ * const wallet = TurnkeyWalletProvider.fromEnv({ expectedChainId: 97 });
+ * const sig = await wallet.signMessage("hello"); // 1 billed signature
+ * ```
+ */
+export class TurnkeyWalletProvider extends WalletProvider {
+ static override readonly kind: string = "turnkey";
+
+ // Arbitrary mechanical contract calls via LocalExecutor; sponsored
+ // broadcast via the MegaFuel paymaster (gasPrice=0 legacy signing verified
+ // against the enclave, probe 2026-07-27). sign.* derive automatically
+ // since all three sign methods below are overridden.
+ protected override readonly extraCapabilities: ReadonlySet = new Set([
+ CALLS_ARBITRARY,
+ PAYMASTER_SPONSOR,
+ ]);
+
+ readonly #address: `0x${string}`;
+ readonly #organizationId: string;
+ readonly #apiPublicKey: string;
+ readonly #apiPrivateKey: string;
+ readonly #apiBaseUrl: string;
+ readonly #expectedChainId: number | undefined;
+ readonly #signingPolicy: SigningPolicy;
+
+ #accountPromise: Promise | null = null;
+
+ constructor(opts: TurnkeyWalletProviderOptions) {
+ super();
+ for (const [key, value] of [
+ ["organizationId", opts.organizationId],
+ ["signWith", opts.signWith],
+ ["apiPublicKey", opts.apiPublicKey],
+ ["apiPrivateKey", opts.apiPrivateKey],
+ ] as const) {
+ if (!value) {
+ throw new Error(`TurnkeyWalletProvider: '${key}' is required`);
+ }
+ }
+ if (!ADDRESS_RE.test(opts.signWith)) {
+ throw new Error(
+ `TURNKEY_SIGN_WITH must be the wallet account's Ethereum address (0x + 40 hex chars), not a Turnkey wallet id or private-key id — copy the address from the Turnkey dashboard wallet-account view. Got: '${opts.signWith}'`,
+ );
+ }
+ this.#address = getAddress(opts.signWith);
+ this.#organizationId = opts.organizationId;
+ this.#apiPublicKey = opts.apiPublicKey;
+ this.#apiPrivateKey = opts.apiPrivateKey;
+ this.#apiBaseUrl = opts.apiBaseUrl || TURNKEY_API_BASE_URL_DEFAULT;
+ this.#expectedChainId = opts.expectedChainId;
+ this.#signingPolicy = opts.signingPolicy ?? SigningPolicy.strictDefault();
+ }
+
+ /**
+ * Build a provider from the `TURNKEY_*` environment variables:
+ * `TURNKEY_API_PUBLIC_KEY`, `TURNKEY_API_PRIVATE_KEY`, `TURNKEY_ORG_ID`,
+ * `TURNKEY_SIGN_WITH` (required) and `TURNKEY_API_BASE_URL` (optional).
+ */
+ static fromEnv(opts: TurnkeyFromEnvOptions = {}): TurnkeyWalletProvider {
+ const values = {
+ TURNKEY_API_PUBLIC_KEY: getEnv("TURNKEY_API_PUBLIC_KEY"),
+ TURNKEY_API_PRIVATE_KEY: getEnv("TURNKEY_API_PRIVATE_KEY"),
+ TURNKEY_ORG_ID: getEnv("TURNKEY_ORG_ID"),
+ TURNKEY_SIGN_WITH: getEnv("TURNKEY_SIGN_WITH"),
+ };
+ const missing = Object.entries(values)
+ .filter(([, v]) => !v)
+ .map(([k]) => k);
+ if (missing.length > 0) {
+ throw new Error(
+ `TurnkeyWalletProvider.fromEnv: missing required env vars: ${missing.join(", ")}. The values come from the Turnkey dashboard (API keys, organization settings, wallet account address).`,
+ );
+ }
+ return new TurnkeyWalletProvider({
+ apiPublicKey: values.TURNKEY_API_PUBLIC_KEY as string,
+ apiPrivateKey: values.TURNKEY_API_PRIVATE_KEY as string,
+ organizationId: values.TURNKEY_ORG_ID as string,
+ signWith: values.TURNKEY_SIGN_WITH as string,
+ apiBaseUrl: getEnv("TURNKEY_API_BASE_URL"),
+ ...(opts.expectedChainId !== undefined
+ ? { expectedChainId: opts.expectedChainId }
+ : {}),
+ ...(opts.signingPolicy ? { signingPolicy: opts.signingPolicy } : {}),
+ });
+ }
+
+ get address(): `0x${string}` {
+ return this.#address;
+ }
+
+ override get keyLocation(): string {
+ return `remote:turnkey (${this.#apiBaseUrl}; key held in AWS Nitro enclave, never leaves)`;
+ }
+
+ /** The SigningPolicy currently enforcing {@link signTypedData} calls. */
+ get signingPolicy(): SigningPolicy {
+ return this.#signingPolicy;
+ }
+
+ /** The chain id this provider is pinned to, if any. */
+ get expectedChainId(): number | undefined {
+ return this.#expectedChainId;
+ }
+
+ /**
+ * Lazily build (and cache) the Turnkey-backed viem account. Concurrent
+ * first callers share one in-flight promise; a rejection clears the cache
+ * so a transient API failure is retryable on the next call.
+ */
+ #account(): Promise {
+ if (!this.#accountPromise) {
+ this.#accountPromise = this.#initAccount().then(
+ (account) => account,
+ (error: unknown) => {
+ this.#accountPromise = null;
+ throw error;
+ },
+ );
+ }
+ return this.#accountPromise;
+ }
+
+ async #initAccount(): Promise {
+ const { sdkServer, viem } = await loadTurnkeySdk();
+ const turnkey = new sdkServer.Turnkey({
+ apiBaseUrl: this.#apiBaseUrl,
+ apiPublicKey: this.#apiPublicKey,
+ apiPrivateKey: this.#apiPrivateKey,
+ defaultOrganizationId: this.#organizationId,
+ });
+ const account = await viem.createAccount({
+ client: turnkey.apiClient(),
+ organizationId: this.#organizationId,
+ signWith: this.#address,
+ });
+ const actual = getAddress(account.address);
+ if (actual !== this.#address) {
+ throw new WalletIdentityMismatch({
+ expected: this.#address,
+ actual,
+ });
+ }
+ return account;
+ }
+
+ /** Run a billable account call with Turnkey-aware error rewriting. */
+ async #vendor(op: string, fn: () => Promise): Promise {
+ try {
+ return await fn();
+ } catch (error) {
+ throw mapTurnkeyError(op, error);
+ }
+ }
+
+ /**
+ * Sign a message using EIP-191 personal sign.
+ *
+ * The digest is hashed locally and blind-signed by the enclave
+ * (`HEXADECIMAL` + `NO_OP`), so Turnkey's server-side policies cannot see
+ * the message content — content-level control lives in this SDK's
+ * client-side policy layer.
+ */
+ override async signMessage(message: string): Promise {
+ const account = await this.#account();
+ const signature = await this.#vendor("signMessage", () =>
+ account.signMessage({ message }),
+ );
+ const { r, s, v } = parseSignature(signature);
+ return {
+ messageHash: hashMessage(message),
+ r,
+ s,
+ v: v as bigint,
+ signature,
+ };
+ }
+
+ /**
+ * Sign EIP-712 typed data after passing the configured
+ * {@link SigningPolicy} — the policy check runs BEFORE the billable API
+ * call, so a refusal costs no quota.
+ *
+ * The full typed-data document goes to the enclave
+ * (`PAYLOAD_ENCODING_EIP712`), where server-side policies can filter on
+ * `eth.eip_712.domain` / `primary_type` / `message`.
+ */
+ override async signTypedData(
+ domain: TypedDataDomain,
+ types: Record,
+ message: Record,
+ ): Promise {
+ check(
+ this.#signingPolicy,
+ domain as Record,
+ types,
+ message,
+ );
+ // Drop a caller-supplied EIP712Domain for hashing parity with the other
+ // providers (identical signatures whether or not it was supplied) …
+ const messageTypes = Object.fromEntries(
+ Object.entries(types).filter(([k]) => k !== "EIP712Domain"),
+ );
+ const primaryType = inferPrimaryType(types);
+ // … then inject the full domain type into the enclave payload:
+ // @turnkey/viem (≤0.14.34) serializes the domain as {} when the types
+ // object lacks an explicit EIP712Domain entry — the signature would
+ // bind an empty domain. See the module docstring.
+ const fullTypes = {
+ EIP712Domain: getTypesForEIP712Domain({ domain }),
+ ...messageTypes,
+ };
+ const account = await this.#account();
+ const signature = await this.#vendor("signTypedData", () =>
+ account.signTypedData({
+ domain,
+ types: fullTypes,
+ primaryType,
+ message,
+ } as Parameters[0]),
+ );
+ const { r, s, v } = parseSignature(signature);
+ const messageHash = hashTypedData({
+ domain,
+ types: messageTypes,
+ primaryType,
+ message,
+ } as Parameters[0]);
+ return { messageHash, r, s, v: v as bigint, signature };
+ }
+
+ /**
+ * Sign a transaction (legacy or EIP-1559 — the serializer infers the type
+ * from the fee fields; both shapes are enclave-verified).
+ * Legacy transactions return the EIP-155 `v`; typed transactions return
+ * the wire-format y-parity bit (`0n` or `1n`).
+ *
+ * When the provider was constructed with `expectedChainId`, a mismatching
+ * `tx.chainId` is refused before the billable API call.
+ */
+ override async signTransaction(tx: SignableTransaction): Promise {
+ if (
+ this.#expectedChainId !== undefined &&
+ tx.chainId !== this.#expectedChainId
+ ) {
+ throw new Error(
+ `Refusing to sign for chainId=${tx.chainId}: this Turnkey provider is pinned to chainId=${this.#expectedChainId} (every Turnkey signature is billed, so the mismatch fails closed before the API call).`,
+ );
+ }
+ const account = await this.#account();
+ const rawTransaction = await this.#vendor("signTransaction", () =>
+ account.signTransaction(tx as unknown as TransactionSerializable),
+ );
+ const parsed = parseTransaction(rawTransaction);
+ const v =
+ parsed.type === "legacy" ? (parsed.v ?? 0n) : BigInt(parsed.yParity ?? 0);
+ return {
+ rawTransaction,
+ hash: keccak256(rawTransaction),
+ r: parsed.r as `0x${string}`,
+ s: parsed.s as `0x${string}`,
+ v,
+ };
+ }
+}
diff --git a/typescript/src/wallets/turnkey/sdkLoader.ts b/typescript/src/wallets/turnkey/sdkLoader.ts
new file mode 100644
index 0000000..b6b7f32
--- /dev/null
+++ b/typescript/src/wallets/turnkey/sdkLoader.ts
@@ -0,0 +1,94 @@
+/**
+ * Lazy loader for the optional `@turnkey/sdk-server` + `@turnkey/viem` peer
+ * dependencies.
+ *
+ * Turnkey is a remote signing service; its SDK packages are declared as
+ * *optional* peerDependencies and this dynamic `import()` pair is the SDK's
+ * ONLY runtime coupling point to them: nothing Turnkey-related loads until a
+ * `TurnkeyWalletProvider` actually needs the backend, and consumers who
+ * never touch Turnkey never need the packages installed.
+ *
+ * The provider always needs both packages together (`@turnkey/sdk-server`
+ * for the authenticated API client, `@turnkey/viem` for the signing
+ * account), so they load behind a single seam and cache as one unit.
+ */
+
+import type { TurnkeySdkModules } from "./types.js";
+
+/** The npm package providing the authenticated Turnkey API client. */
+export const TURNKEY_SDK_SERVER_PACKAGE = "@turnkey/sdk-server";
+
+/** The npm package providing the viem signing-account adapter. */
+export const TURNKEY_VIEM_PACKAGE = "@turnkey/viem";
+
+/** One of the two optional Turnkey packages the provider binds to. */
+export type TurnkeyPackageName =
+ | typeof TURNKEY_SDK_SERVER_PACKAGE
+ | typeof TURNKEY_VIEM_PACKAGE;
+
+/** Host-supplied loader for the optional Turnkey SDK packages. */
+export type TurnkeySdkImporter = (pkg: TurnkeyPackageName) => Promise;
+
+const realImporter: TurnkeySdkImporter = (pkg) => import(pkg);
+
+let importTurnkeyModule: TurnkeySdkImporter = realImporter;
+let cachedModules: Promise | null = null;
+
+/** Whether `error` is any runtime's flavor of "that module isn't installed". */
+function isModuleNotFound(error: unknown): boolean {
+ const code = (error as { code?: unknown } | null)?.code;
+ if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
+ return true;
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ return (
+ /cannot find (module|package)/i.test(message) &&
+ (message.includes(TURNKEY_SDK_SERVER_PACKAGE) ||
+ message.includes(TURNKEY_VIEM_PACKAGE))
+ );
+}
+
+/**
+ * Import both Turnkey packages, caching the combined module promise for the
+ * process lifetime. A missing install is rewritten into an actionable error
+ * naming the exact `pnpm add` to run; the failed promise is NOT cached, so
+ * the next call retries cleanly.
+ */
+export async function loadTurnkeySdk(): Promise {
+ if (!cachedModules) {
+ cachedModules = Promise.all([
+ importTurnkeyModule(TURNKEY_SDK_SERVER_PACKAGE),
+ importTurnkeyModule(TURNKEY_VIEM_PACKAGE),
+ ]).then(
+ ([sdkServer, viem]) =>
+ ({ sdkServer, viem }) as unknown as TurnkeySdkModules,
+ (error: unknown) => {
+ cachedModules = null;
+ if (isModuleNotFound(error)) {
+ throw new Error(
+ `The Turnkey wallet provider requires the optional peer dependencies '${TURNKEY_SDK_SERVER_PACKAGE}' and '${TURNKEY_VIEM_PACKAGE}' (not installed). Install them with: pnpm add ${TURNKEY_SDK_SERVER_PACKAGE} ${TURNKEY_VIEM_PACKAGE}`,
+ { cause: error },
+ );
+ }
+ throw error;
+ },
+ );
+ }
+ return cachedModules;
+}
+
+/**
+ * Swap the dynamic-import implementation, or restore the package-relative
+ * default with `null`.
+ *
+ * Hosts such as a globally installed CLI use this seam to resolve the
+ * optional Turnkey packages from an agent project's own `node_modules`.
+ * Changing the importer always clears the process cache so the next Turnkey
+ * operation uses the newly selected module source.
+ */
+export function setTurnkeySdkImporter(
+ importer: TurnkeySdkImporter | null,
+): void {
+ importTurnkeyModule = importer ?? realImporter;
+ cachedModules = null;
+}
diff --git a/typescript/src/wallets/turnkey/types.ts b/typescript/src/wallets/turnkey/types.ts
new file mode 100644
index 0000000..febe062
--- /dev/null
+++ b/typescript/src/wallets/turnkey/types.ts
@@ -0,0 +1,70 @@
+/**
+ * Structural mirrors of the `@turnkey/sdk-server` + `@turnkey/viem` public
+ * types the provider touches.
+ *
+ * Both Turnkey packages are **optional** peer dependencies, so nothing in
+ * `src/` may import from them at the type level — otherwise the generated
+ * `.d.ts` bundle would carry `import("@turnkey/…")` references that break
+ * consumers who never installed the peers. These mirrors keep our public
+ * declaration files self-contained; `tests/turnkeyTypeCompat.test.ts` pins
+ * assignability against the real packages (devDependencies) so any drift
+ * fails `pnpm typecheck` instead of surfacing at runtime.
+ *
+ * Mirrored from `@turnkey/sdk-server@8.1.0` (`TurnkeySDKServerConfig`,
+ * `TurnkeyServerSDK`) and `@turnkey/viem@0.14.34` (`createAccount`). Only
+ * the slice the provider calls is mirrored — the packages export far more.
+ */
+
+import type { LocalAccount } from "viem";
+
+/**
+ * Constructor config for the Turnkey server SDK (mirrors
+ * `TurnkeySDKServerConfig`, required fields only). The API key pair is a
+ * locally held P-256 keypair that stamps (signs) every request body — the
+ * private key is a client credential, never sent over the wire.
+ */
+export interface TurnkeyClientConfig {
+ apiBaseUrl: string;
+ apiPublicKey: string;
+ apiPrivateKey: string;
+ defaultOrganizationId: string;
+}
+
+/**
+ * Opaque handle for the authenticated Turnkey API client returned by
+ * `Turnkey#apiClient()`. The provider only threads it through to
+ * `createAccount`, so no surface is mirrored.
+ */
+export type TurnkeyApiClient = object;
+
+/** The `Turnkey` server SDK instance surface the provider uses. */
+export interface TurnkeyServerSdk {
+ apiClient(): TurnkeyApiClient;
+}
+
+/** Module shape of `@turnkey/sdk-server` (the slice the provider uses). */
+export interface TurnkeySdkServerModule {
+ Turnkey: new (config: TurnkeyClientConfig) => TurnkeyServerSdk;
+}
+
+/**
+ * Module shape of `@turnkey/viem` (the slice the provider uses).
+ *
+ * `createAccount` returns a standard viem `LocalAccount` whose sign methods
+ * round-trip through the Turnkey API; with `signWith` set to the account's
+ * Ethereum address it performs no network call at construction.
+ */
+export interface TurnkeyViemModule {
+ createAccount(input: {
+ client: TurnkeyApiClient;
+ organizationId: string;
+ signWith: string;
+ ethereumAddress?: string;
+ }): Promise;
+}
+
+/** Both lazily imported Turnkey packages, loaded and cached as one unit. */
+export interface TurnkeySdkModules {
+ sdkServer: TurnkeySdkServerModule;
+ viem: TurnkeyViemModule;
+}
diff --git a/typescript/src/wallets/walletProvider.ts b/typescript/src/wallets/walletProvider.ts
index 597fae8..4270582 100644
--- a/typescript/src/wallets/walletProvider.ts
+++ b/typescript/src/wallets/walletProvider.ts
@@ -33,7 +33,12 @@ export type SignableTransaction = TransactionRequestLegacy & {
chainId: number;
};
-/** Result of {@link WalletProvider.signTransaction}. */
+/**
+ * Result of {@link WalletProvider.signTransaction}.
+ *
+ * For legacy transactions, `v` is the EIP-155 value. For typed transactions,
+ * it is the wire-format y-parity bit (`0n` or `1n`).
+ */
export interface SignedTx {
rawTransaction: `0x${string}`;
hash: `0x${string}`;
diff --git a/typescript/tests/erc8183Config.test.ts b/typescript/tests/erc8183Config.test.ts
index 139a4df..b7a4539 100644
--- a/typescript/tests/erc8183Config.test.ts
+++ b/typescript/tests/erc8183Config.test.ts
@@ -46,6 +46,10 @@ vi.mock("node:os", async (importOriginal) => {
});
const { ERC8183Config } = await import("../src/erc8183/config.js");
+const { TurnkeyWalletProvider } = await import(
+ "../src/wallets/turnkey/provider.js"
+);
+const { WalletIdentityMismatch } = await import("../src/wallets/errors.js");
const VALID_PK = `0x${"cd".repeat(32)}`;
const VALID_PASSWORD = "test-password";
@@ -85,6 +89,11 @@ const ENV_KEYS = [
"ERC8183_SERVICE_PRICE",
"ERC8183_AGENT_URL",
"STORAGE_LOCAL_PATH",
+ "TURNKEY_API_PUBLIC_KEY",
+ "TURNKEY_API_PRIVATE_KEY",
+ "TURNKEY_ORG_ID",
+ "TURNKEY_SIGN_WITH",
+ "TURNKEY_API_BASE_URL",
] as const;
let saved: Record;
@@ -258,6 +267,73 @@ describe("ERC8183Config: wallet_kind", () => {
});
});
+describe("ERC8183Config: wallet_kind=turnkey", () => {
+ const TURNKEY_SIGN_WITH = `0x${"7a".repeat(20)}`;
+
+ const setTurnkeyEnv = () => {
+ process.env.TURNKEY_API_PUBLIC_KEY = "02".repeat(33);
+ process.env.TURNKEY_API_PRIVATE_KEY = "aa".repeat(32);
+ process.env.TURNKEY_ORG_ID = "org-123";
+ process.env.TURNKEY_SIGN_WITH = TURNKEY_SIGN_WITH;
+ };
+
+ it("dispatches to TurnkeyWalletProvider pinned to the network's chain id", () => {
+ setTurnkeyEnv();
+ const config = new ERC8183Config({ walletKind: "turnkey" });
+ expect(config.walletProvider).toBeInstanceOf(TurnkeyWalletProvider);
+ const provider = config.walletProvider as InstanceType<
+ typeof TurnkeyWalletProvider
+ >;
+ // Default network is bsc-testnet → fail-closed pin to 97.
+ expect(provider.expectedChainId).toBe(97);
+ expect(provider.address.toLowerCase()).toBe(TURNKEY_SIGN_WITH);
+ });
+
+ it("surfaces the missing TURNKEY_* env vars in one error", () => {
+ process.env.TURNKEY_API_PUBLIC_KEY = "02".repeat(33);
+ expect(() => new ERC8183Config({ walletKind: "turnkey" })).toThrow(
+ /missing required env vars: TURNKEY_API_PRIVATE_KEY, TURNKEY_ORG_ID, TURNKEY_SIGN_WITH/,
+ );
+ });
+
+ it("fails closed on a WALLET_ADDRESS drift (WalletIdentityMismatch)", () => {
+ setTurnkeyEnv();
+ expect(
+ () =>
+ new ERC8183Config({
+ walletKind: "turnkey",
+ walletAddress: `0x${"9b".repeat(20)}`,
+ }),
+ ).toThrow(WalletIdentityMismatch);
+ });
+
+ it("accepts a matching WALLET_ADDRESS anchor (case-insensitive)", () => {
+ setTurnkeyEnv();
+ const config = new ERC8183Config({
+ walletKind: "turnkey",
+ walletAddress: TURNKEY_SIGN_WITH.toUpperCase().replace("0X", "0x"),
+ });
+ expect(config.walletProvider).toBeInstanceOf(TurnkeyWalletProvider);
+ });
+
+ it("treats wallet_kind as advisory when a wallet_provider is supplied", () => {
+ const stub = new StubWallet(`0x${"11".repeat(20)}`);
+ const config = new ERC8183Config({
+ walletKind: "turnkey",
+ walletProvider: stub,
+ });
+ expect(config.walletProvider).toBe(stub);
+ });
+
+ it("fromEnv builds a turnkey wallet without requiring WALLET_PASSWORD", () => {
+ setTurnkeyEnv();
+ process.env.WALLET_KIND = "turnkey";
+ const config = ERC8183Config.fromEnv(new FakeStorage());
+ expect(config.walletProvider).toBeInstanceOf(TurnkeyWalletProvider);
+ expect(config.walletKind).toBe("turnkey");
+ });
+});
+
describe("ERC8183Config.fromEnv", () => {
it("resolves rpc_url and ERC8183_*_ADDRESS overrides from env", () => {
process.env.RPC_URL = "https://rpc.example.com";
diff --git a/typescript/tests/publicApi.test.ts b/typescript/tests/publicApi.test.ts
index 02b23c0..0f05ff4 100644
--- a/typescript/tests/publicApi.test.ts
+++ b/typescript/tests/publicApi.test.ts
@@ -66,6 +66,8 @@ describe("Tier 1 public API (src/index.ts)", () => {
expect(typeof Tier1.EVMWalletProvider).toBe("function");
expect(typeof Tier1.AltanaWalletProvider).toBe("function");
expect(Tier1.AltanaWalletProvider.kind).toBe("altana");
+ expect(typeof Tier1.TurnkeyWalletProvider).toBe("function");
+ expect(Tier1.TurnkeyWalletProvider.kind).toBe("turnkey");
});
it("exports ERC-8183 essentials", () => {
@@ -201,6 +203,18 @@ describe("Tier 2 subpath: ./wallets", () => {
expect(typeof Wallets.ALTANA_NONCE_RETRY_TRIES).toBe("number");
expect(typeof Wallets.ALTANA_NONCE_RETRY_DELAY_MS).toBe("number");
});
+
+ it("exports the turnkey surface (provider, loader seam, constants)", () => {
+ expect(typeof Wallets.TurnkeyWalletProvider).toBe("function");
+ expect(Wallets.TurnkeyWalletProvider.kind).toBe("turnkey");
+ expect(Wallets.TURNKEY_SDK_SERVER_PACKAGE).toBe("@turnkey/sdk-server");
+ expect(Wallets.TURNKEY_VIEM_PACKAGE).toBe("@turnkey/viem");
+ expect(typeof Wallets.setTurnkeySdkImporter).toBe("function");
+ expect(typeof Wallets.TURNKEY_API_BASE_URL_DEFAULT).toBe("string");
+ expect(Wallets.TURNKEY_API_BASE_URL_DEFAULT.startsWith("https://")).toBe(
+ true,
+ );
+ });
});
describe("Tier 2 subpath: ./signing", () => {
diff --git a/typescript/tests/turnkeyProvider.test.ts b/typescript/tests/turnkeyProvider.test.ts
new file mode 100644
index 0000000..b078d7e
--- /dev/null
+++ b/typescript/tests/turnkeyProvider.test.ts
@@ -0,0 +1,653 @@
+/**
+ * `TurnkeyWalletProvider` conformance (`src/wallets/turnkey/provider.ts`):
+ * capability surface, synchronous address, lazy account init (concurrency +
+ * retry), the EIP712Domain injection trap regression, policy-before-billing
+ * ordering, legacy/1559 transaction round-trips, `fromEnv`, vendor error
+ * mapping, and the missing-peer install guidance.
+ *
+ * `@turnkey/sdk-server` / `@turnkey/viem` are mocked at the module level
+ * (the provider only ever reaches them through the lazy loader), with
+ * `createAccount` returning a real viem `privateKeyToAccount` wrapped to
+ * RECORD the exact call arguments — signatures stay real and recoverable,
+ * so domain binding is provable with `recoverTypedDataAddress`.
+ */
+
+import {
+ type TypedDataDomain,
+ getAddress,
+ getTypesForEIP712Domain,
+ hashMessage,
+ hashTypedData,
+ keccak256,
+ parseTransaction,
+ recoverTypedDataAddress,
+} from "viem";
+import { privateKeyToAccount } from "viem/accounts";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { PolicyViolation } from "../src/signing/errors.js";
+import { SigningPolicy } from "../src/signing/policy.js";
+import {
+ CALLS_ARBITRARY,
+ PAYMASTER_SPONSOR,
+ SIGN_MESSAGE,
+ SIGN_TRANSACTION,
+ SIGN_TYPED_DATA,
+} from "../src/wallets/capabilities.js";
+import { WalletIdentityMismatch } from "../src/wallets/errors.js";
+
+const tkMocks = vi.hoisted(() => ({
+ // Every `new Turnkey(config)` records its config here.
+ ctorConfigs: [] as Record[],
+ apiClientCalls: { count: 0 },
+ // Every `createAccount(input)` records its input here.
+ createAccountCalls: [] as Record[],
+ // Test-swappable behavior; `null` = default faithful account.
+ createAccountImpl: {
+ fn: null as null | ((input: unknown) => Promise),
+ },
+ // Recorded arguments of account.sign* calls (the enclave payload).
+ recorded: {
+ messages: [] as unknown[],
+ typedData: [] as Record[],
+ transactions: [] as unknown[],
+ },
+ // Test-injected failure thrown by the account's sign methods.
+ signFailure: { error: null as unknown },
+ // Counts module-factory executions (module load observability).
+ factoryRuns: { count: 0 },
+}));
+
+// The Turnkey-hosted key the mock "enclave" signs with. `SIGN_WITH` is its
+// address, so the provider's identity check passes by default.
+const TEST_PK: `0x${string}` = `0x${"c3".repeat(32)}`;
+const SIGN_WITH = privateKeyToAccount(TEST_PK).address;
+
+vi.mock("@turnkey/sdk-server", () => {
+ tkMocks.factoryRuns.count += 1;
+ class Turnkey {
+ readonly config: Record;
+ constructor(config: Record) {
+ this.config = config;
+ tkMocks.ctorConfigs.push(config);
+ }
+ apiClient(): object {
+ tkMocks.apiClientCalls.count += 1;
+ return { __tag: "turnkey-api-client", config: this.config };
+ }
+ }
+ return { Turnkey };
+});
+
+vi.mock("@turnkey/viem", () => {
+ tkMocks.factoryRuns.count += 1;
+ return {
+ createAccount: async (input: Record) => {
+ tkMocks.createAccountCalls.push(input);
+ if (tkMocks.createAccountImpl.fn) {
+ return tkMocks.createAccountImpl.fn(input);
+ }
+ const backing = privateKeyToAccount(TEST_PK);
+ const failing = () => {
+ if (tkMocks.signFailure.error !== null) {
+ throw tkMocks.signFailure.error;
+ }
+ };
+ return {
+ ...backing,
+ signMessage: (args: unknown) => {
+ failing();
+ tkMocks.recorded.messages.push(args);
+ return backing.signMessage(
+ args as Parameters[0],
+ );
+ },
+ signTypedData: (args: Record) => {
+ failing();
+ tkMocks.recorded.typedData.push(args);
+ return backing.signTypedData(
+ args as Parameters[0],
+ );
+ },
+ signTransaction: (args: unknown) => {
+ failing();
+ tkMocks.recorded.transactions.push(args);
+ return backing.signTransaction(
+ args as Parameters[0],
+ );
+ },
+ };
+ },
+ };
+});
+
+const { TURNKEY_API_BASE_URL_DEFAULT, TurnkeyWalletProvider } = await import(
+ "../src/wallets/turnkey/provider.js"
+);
+const {
+ TURNKEY_SDK_SERVER_PACKAGE,
+ TURNKEY_VIEM_PACKAGE,
+ loadTurnkeySdk,
+ setTurnkeySdkImporter,
+} = await import("../src/wallets/turnkey/sdkLoader.js");
+
+const BASE_OPTS = {
+ organizationId: "org-123",
+ signWith: SIGN_WITH,
+ apiPublicKey: "02".repeat(33),
+ apiPrivateKey: "aa".repeat(32),
+};
+
+// A domain NOT in knownPaymentTokens — strictDefault must refuse it.
+const TEST_DOMAIN: TypedDataDomain = {
+ name: "TestToken",
+ version: "1",
+ chainId: 97,
+ verifyingContract: getAddress(`0x${"22".repeat(20)}`),
+};
+
+// Canonical EIP-3009 shape (mirrors EIP3009_CANONICAL_FIELDS) with a
+// validity window inside strictDefault's 600s cap.
+function eip3009Fixture() {
+ const nowSec = Math.floor(Date.now() / 1000);
+ return {
+ types: {
+ TransferWithAuthorization: [
+ { name: "from", type: "address" },
+ { name: "to", type: "address" },
+ { name: "value", type: "uint256" },
+ { name: "validAfter", type: "uint256" },
+ { name: "validBefore", type: "uint256" },
+ { name: "nonce", type: "bytes32" },
+ ],
+ },
+ message: {
+ from: SIGN_WITH,
+ to: getAddress(`0x${"33".repeat(20)}`),
+ value: 1n,
+ validAfter: BigInt(nowSec - 10),
+ validBefore: BigInt(nowSec + 580),
+ nonce: `0x${"44".repeat(32)}`,
+ },
+ };
+}
+
+/** Policy that allowlists TEST_DOMAIN on top of strict defaults. */
+function extendedPolicy(): SigningPolicy {
+ return SigningPolicy.strictDefault().extend({
+ domainAllowlist: [
+ [97, TEST_DOMAIN.verifyingContract as `0x${string}`],
+ ] as const,
+ });
+}
+
+const TURNKEY_ENV_KEYS = [
+ "TURNKEY_API_PUBLIC_KEY",
+ "TURNKEY_API_PRIVATE_KEY",
+ "TURNKEY_ORG_ID",
+ "TURNKEY_SIGN_WITH",
+ "TURNKEY_API_BASE_URL",
+] as const;
+const savedEnv: Record = {};
+
+beforeEach(() => {
+ for (const key of TURNKEY_ENV_KEYS) {
+ savedEnv[key] = process.env[key];
+ delete process.env[key];
+ }
+ setTurnkeySdkImporter(null);
+ tkMocks.ctorConfigs.length = 0;
+ tkMocks.apiClientCalls.count = 0;
+ tkMocks.createAccountCalls.length = 0;
+ tkMocks.createAccountImpl.fn = null;
+ tkMocks.recorded.messages.length = 0;
+ tkMocks.recorded.typedData.length = 0;
+ tkMocks.recorded.transactions.length = 0;
+ tkMocks.signFailure.error = null;
+});
+
+afterEach(() => {
+ for (const key of TURNKEY_ENV_KEYS) {
+ if (savedEnv[key] === undefined) delete process.env[key];
+ else process.env[key] = savedEnv[key];
+ }
+ setTurnkeySdkImporter(null);
+});
+
+describe("construction and capability surface", () => {
+ it("constructs offline: no module load, no client, no account", () => {
+ // Declaration-order sensitive: this is the file's first executed test,
+ // so the vi.mock factories must not have run yet — construction and
+ // describe() must not touch the optional packages at all.
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ const description = provider.describe();
+ expect(description.kind).toBe("turnkey");
+ expect(tkMocks.factoryRuns.count).toBe(0);
+ expect(tkMocks.createAccountCalls.length).toBe(0);
+ expect(tkMocks.apiClientCalls.count).toBe(0);
+ });
+
+ it("declares the pure-signer capability set", () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ expect(provider.capabilities()).toEqual(
+ new Set([
+ SIGN_MESSAGE,
+ SIGN_TRANSACTION,
+ SIGN_TYPED_DATA,
+ CALLS_ARBITRARY,
+ PAYMASTER_SPONSOR,
+ ]),
+ );
+ expect(TurnkeyWalletProvider.kind).toBe("turnkey");
+ });
+
+ it("address is synchronous and checksummed from lowercase input", () => {
+ const provider = new TurnkeyWalletProvider({
+ ...BASE_OPTS,
+ signWith: SIGN_WITH.toLowerCase(),
+ });
+ expect(provider.address).toBe(SIGN_WITH);
+ });
+
+ it("describe() reports the remote key location and never leaks credentials", () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ const description = provider.describe();
+ expect(description.address).toBe(SIGN_WITH);
+ expect(description.keyLocation).toContain("remote:turnkey");
+ expect(description.keyLocation).toContain(TURNKEY_API_BASE_URL_DEFAULT);
+ expect(description.exists).toBe(true);
+ expect(JSON.stringify(description)).not.toContain(BASE_OPTS.apiPrivateKey);
+ });
+
+ it.each([
+ ["a Turnkey wallet id (UUID)", "3f2504e0-4f89-11d3-9a0c-0305e82c3301"],
+ ["a private-key id", "pk-12345"],
+ ["a 39-hex-char near-address", `0x${"ab".repeat(19)}a`],
+ ["an empty string", ""],
+ ])("rejects signWith that is %s", (_label, signWith) => {
+ expect(() => new TurnkeyWalletProvider({ ...BASE_OPTS, signWith })).toThrow(
+ signWith === ""
+ ? /'signWith' is required/
+ : /must be the wallet account's Ethereum address/,
+ );
+ });
+
+ it.each(["organizationId", "apiPublicKey", "apiPrivateKey"] as const)(
+ "requires %s to be non-empty",
+ (key) => {
+ expect(
+ () => new TurnkeyWalletProvider({ ...BASE_OPTS, [key]: "" }),
+ ).toThrow(new RegExp(`'${key}' is required`));
+ },
+ );
+});
+
+describe("lazy account initialization", () => {
+ it("concurrent first calls share exactly one createAccount round-trip", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ await Promise.all([
+ provider.signMessage("one"),
+ provider.signMessage("two"),
+ provider.signMessage("three"),
+ ]);
+ expect(tkMocks.createAccountCalls.length).toBe(1);
+ expect(tkMocks.apiClientCalls.count).toBe(1);
+ expect(tkMocks.ctorConfigs).toEqual([
+ {
+ apiBaseUrl: TURNKEY_API_BASE_URL_DEFAULT,
+ apiPublicKey: BASE_OPTS.apiPublicKey,
+ apiPrivateKey: BASE_OPTS.apiPrivateKey,
+ defaultOrganizationId: BASE_OPTS.organizationId,
+ },
+ ]);
+ });
+
+ it("a failed init is not cached — the next call retries and succeeds", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ tkMocks.createAccountImpl.fn = () =>
+ Promise.reject(new Error("transient network failure"));
+ await expect(provider.signMessage("x")).rejects.toThrow(
+ /transient network failure/,
+ );
+ tkMocks.createAccountImpl.fn = null;
+ const result = await provider.signMessage("x");
+ expect(result.messageHash).toBe(hashMessage("x"));
+ expect(tkMocks.createAccountCalls.length).toBe(2);
+ });
+
+ it("fails closed with WalletIdentityMismatch when the backend resolves another address", async () => {
+ const other = privateKeyToAccount(`0x${"d4".repeat(32)}`);
+ tkMocks.createAccountImpl.fn = async () => other;
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ await expect(provider.signMessage("x")).rejects.toThrow(
+ WalletIdentityMismatch,
+ );
+ });
+
+ it("honors a custom apiBaseUrl", async () => {
+ const provider = new TurnkeyWalletProvider({
+ ...BASE_OPTS,
+ apiBaseUrl: "https://api.turnkey.example",
+ });
+ expect(provider.keyLocation).toContain("https://api.turnkey.example");
+ await provider.signMessage("x");
+ expect(tkMocks.ctorConfigs[0]?.apiBaseUrl).toBe(
+ "https://api.turnkey.example",
+ );
+ });
+});
+
+describe("signMessage (EIP-191)", () => {
+ it("round-trips: digest matches hashMessage and the signature recovers", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ const result = await provider.signMessage("hello turnkey");
+ expect(result.messageHash).toBe(hashMessage("hello turnkey"));
+ expect(result.signature).toMatch(/^0x[0-9a-f]{130}$/);
+ expect([27n, 28n]).toContain(result.v);
+ const reference = await privateKeyToAccount(TEST_PK).signMessage({
+ message: "hello turnkey",
+ });
+ expect(result.signature).toBe(reference);
+ });
+});
+
+describe("signTypedData (EIP-712)", () => {
+ it("policy check runs BEFORE any billable call (strict default refuses unknown domain)", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ const { types, message } = eip3009Fixture();
+ await expect(
+ provider.signTypedData(TEST_DOMAIN, types, message),
+ ).rejects.toThrow(PolicyViolation);
+ expect(tkMocks.createAccountCalls.length).toBe(0);
+ expect(tkMocks.recorded.typedData.length).toBe(0);
+ });
+
+ it("signs after the policy is extended with the domain", async () => {
+ const provider = new TurnkeyWalletProvider({
+ ...BASE_OPTS,
+ signingPolicy: extendedPolicy(),
+ });
+ const { types, message } = eip3009Fixture();
+ const result = await provider.signTypedData(TEST_DOMAIN, types, message);
+ expect(result.signature).toMatch(/^0x[0-9a-f]{130}$/);
+ });
+
+ it("injects the full EIP712Domain type into the enclave payload (the 0.14.x stripping trap)", async () => {
+ const provider = new TurnkeyWalletProvider({
+ ...BASE_OPTS,
+ signingPolicy: extendedPolicy(),
+ });
+ const { types, message } = eip3009Fixture();
+ await provider.signTypedData(TEST_DOMAIN, types, message);
+
+ const sent = tkMocks.recorded.typedData[0] as {
+ domain: TypedDataDomain;
+ types: Record;
+ primaryType: string;
+ };
+ expect(sent.primaryType).toBe("TransferWithAuthorization");
+ expect(sent.types.EIP712Domain).toEqual(
+ getTypesForEIP712Domain({ domain: TEST_DOMAIN }),
+ );
+ expect(sent.domain).toEqual(TEST_DOMAIN);
+ });
+
+ it("replaces a caller-supplied EIP712Domain and signs identically with or without it", async () => {
+ const { types, message } = eip3009Fixture();
+ const withBogusDomainType = {
+ // Deliberately wrong shape — must be replaced, not trusted.
+ EIP712Domain: [{ name: "name", type: "string" }],
+ ...types,
+ };
+
+ const provider = new TurnkeyWalletProvider({
+ ...BASE_OPTS,
+ signingPolicy: extendedPolicy(),
+ });
+ const withSupplied = await provider.signTypedData(
+ TEST_DOMAIN,
+ withBogusDomainType,
+ message,
+ );
+ const withoutSupplied = await provider.signTypedData(
+ TEST_DOMAIN,
+ types,
+ message,
+ );
+ expect(withSupplied.signature).toBe(withoutSupplied.signature);
+
+ for (const sent of tkMocks.recorded.typedData as {
+ types: Record;
+ }[]) {
+ expect(sent.types.EIP712Domain).toEqual(
+ getTypesForEIP712Domain({ domain: TEST_DOMAIN }),
+ );
+ }
+ });
+
+ it("binds the real domain: recoverTypedDataAddress yields the provider address", async () => {
+ const provider = new TurnkeyWalletProvider({
+ ...BASE_OPTS,
+ signingPolicy: extendedPolicy(),
+ });
+ const { types, message } = eip3009Fixture();
+ const result = await provider.signTypedData(TEST_DOMAIN, types, message);
+ const recovered = await recoverTypedDataAddress({
+ domain: TEST_DOMAIN,
+ types,
+ primaryType: "TransferWithAuthorization",
+ message,
+ signature: result.signature,
+ });
+ expect(recovered).toBe(provider.address);
+ expect(result.messageHash).toBe(
+ hashTypedData({
+ domain: TEST_DOMAIN,
+ types,
+ primaryType: "TransferWithAuthorization",
+ message,
+ } as Parameters[0]),
+ );
+ });
+
+ it("rejects multi-struct types (primary-type ambiguity) before any billable call", async () => {
+ const provider = new TurnkeyWalletProvider({
+ ...BASE_OPTS,
+ signingPolicy: extendedPolicy(),
+ });
+ const { types, message } = eip3009Fixture();
+ const multi = {
+ ...types,
+ Extra: [{ name: "x", type: "uint256" }],
+ };
+ await expect(
+ provider.signTypedData(TEST_DOMAIN, multi, message),
+ ).rejects.toThrow(PolicyViolation);
+ expect(tkMocks.createAccountCalls.length).toBe(0);
+ });
+});
+
+describe("signTransaction", () => {
+ const legacyTx = {
+ chainId: 97,
+ to: getAddress(`0x${"55".repeat(20)}`),
+ value: 1n,
+ nonce: 0,
+ gas: 21_000n,
+ gasPrice: 10_000_000_000n,
+ };
+
+ it("legacy round-trip: serialized as legacy, hash and r/s/v populated", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ const signed = await provider.signTransaction(legacyTx);
+ const parsed = parseTransaction(signed.rawTransaction);
+ expect(parsed.type).toBe("legacy");
+ expect(parsed.gasPrice).toBe(legacyTx.gasPrice);
+ expect(signed.hash).toBe(keccak256(signed.rawTransaction));
+ // EIP-155 v for chainId 97: 2*97 + 35/36.
+ expect([229n, 230n]).toContain(signed.v);
+ expect(signed.r).toMatch(/^0x/);
+ expect(signed.s).toMatch(/^0x/);
+ });
+
+ it("EIP-1559 round-trip: fee fields select the typed transaction", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ const signed = await provider.signTransaction({
+ chainId: 97,
+ to: getAddress(`0x${"55".repeat(20)}`),
+ value: 1n,
+ nonce: 0,
+ gas: 21_000n,
+ maxFeePerGas: 10_000_000_000n,
+ maxPriorityFeePerGas: 1_000_000_000n,
+ } as unknown as typeof legacyTx);
+ const parsed = parseTransaction(signed.rawTransaction);
+ expect(parsed.type).toBe("eip1559");
+ expect(signed.hash).toBe(keccak256(signed.rawTransaction));
+ // Typed transactions expose the wire-format yParity bit as v.
+ expect([0n, 1n]).toContain(signed.v);
+ });
+
+ it("refuses a chainId mismatch before any billable call when pinned", async () => {
+ const provider = new TurnkeyWalletProvider({
+ ...BASE_OPTS,
+ expectedChainId: 97,
+ });
+ await expect(
+ provider.signTransaction({ ...legacyTx, chainId: 56 }),
+ ).rejects.toThrow(/pinned to chainId=97/);
+ expect(tkMocks.createAccountCalls.length).toBe(0);
+ expect(provider.expectedChainId).toBe(97);
+ });
+
+ it("unpinned providers sign for any chainId", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ const signed = await provider.signTransaction({
+ ...legacyTx,
+ chainId: 56,
+ });
+ expect(parseTransaction(signed.rawTransaction).chainId).toBe(56);
+ });
+});
+
+describe("fromEnv", () => {
+ const setAllEnv = () => {
+ process.env.TURNKEY_API_PUBLIC_KEY = BASE_OPTS.apiPublicKey;
+ process.env.TURNKEY_API_PRIVATE_KEY = BASE_OPTS.apiPrivateKey;
+ process.env.TURNKEY_ORG_ID = BASE_OPTS.organizationId;
+ process.env.TURNKEY_SIGN_WITH = SIGN_WITH;
+ };
+
+ it("builds a provider from the four required env vars (default base URL)", () => {
+ setAllEnv();
+ const provider = TurnkeyWalletProvider.fromEnv({ expectedChainId: 97 });
+ expect(provider.address).toBe(SIGN_WITH);
+ expect(provider.expectedChainId).toBe(97);
+ expect(provider.keyLocation).toContain(TURNKEY_API_BASE_URL_DEFAULT);
+ });
+
+ it("honors TURNKEY_API_BASE_URL and a supplied signingPolicy", () => {
+ setAllEnv();
+ process.env.TURNKEY_API_BASE_URL = "https://api.turnkey.example";
+ const policy = extendedPolicy();
+ const provider = TurnkeyWalletProvider.fromEnv({ signingPolicy: policy });
+ expect(provider.keyLocation).toContain("https://api.turnkey.example");
+ expect(provider.signingPolicy).toBe(policy);
+ });
+
+ it("names ALL missing env vars in one error", () => {
+ process.env.TURNKEY_API_PUBLIC_KEY = BASE_OPTS.apiPublicKey;
+ expect(() => TurnkeyWalletProvider.fromEnv()).toThrow(
+ /missing required env vars: TURNKEY_API_PRIVATE_KEY, TURNKEY_ORG_ID, TURNKEY_SIGN_WITH/,
+ );
+ });
+});
+
+describe("vendor error mapping", () => {
+ it("rewrites rate-limit failures with the 1 RPS hint", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ await provider.signMessage("warm up");
+ tkMocks.signFailure.error = Object.assign(
+ new Error("Turnkey error 8: RATE_LIMIT_EXCEEDED"),
+ { status: 429 },
+ );
+ await expect(provider.signMessage("x")).rejects.toThrow(
+ /1 request\/second/,
+ );
+ });
+
+ it("rewrites quota exhaustion with the billing hint and preserves the cause", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ await provider.signMessage("warm up");
+ const original = new Error("SIGNING_QUOTA_EXCEEDED for organization");
+ tkMocks.signFailure.error = original;
+ const failure = await provider.signMessage("x").catch((e: unknown) => e);
+ expect((failure as Error).message).toMatch(/25 billed signatures\/month/);
+ expect((failure as Error).cause).toBe(original);
+ });
+
+ it("rewrites policy denials with the non-root ALLOW-policy hint", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ await provider.signMessage("warm up");
+ tkMocks.signFailure.error = new Error(
+ "Turnkey error 7: policy engine rejected the activity (POLICY_REJECTED)",
+ );
+ await expect(provider.signMessage("x")).rejects.toThrow(
+ /explicit ALLOW policy/,
+ );
+ });
+
+ it("passes unrecognized errors through untouched", async () => {
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ await provider.signMessage("warm up");
+ const original = new Error("something else entirely");
+ tkMocks.signFailure.error = original;
+ const failure = await provider.signMessage("x").catch((e: unknown) => e);
+ expect(failure).toBe(original);
+ });
+});
+
+describe("sdk loader", () => {
+ it("maps a module-not-found rejection to the pnpm add guidance", async () => {
+ const notFound = Object.assign(
+ new Error(`Cannot find package '${TURNKEY_SDK_SERVER_PACKAGE}'`),
+ { code: "ERR_MODULE_NOT_FOUND" },
+ );
+ setTurnkeySdkImporter(() => Promise.reject(notFound));
+ const failure = await loadTurnkeySdk().catch((e: unknown) => e);
+ expect((failure as Error).message).toContain(
+ `pnpm add ${TURNKEY_SDK_SERVER_PACKAGE} ${TURNKEY_VIEM_PACKAGE}`,
+ );
+ expect((failure as Error).cause).toBe(notFound);
+ });
+
+ it("passes unrelated importer failures through", async () => {
+ const boom = new Error("permission denied");
+ setTurnkeySdkImporter(() => Promise.reject(boom));
+ await expect(loadTurnkeySdk()).rejects.toBe(boom);
+ });
+
+ it("does not cache failures — each call retries the importer", async () => {
+ let calls = 0;
+ setTurnkeySdkImporter((pkg) => {
+ calls += 1;
+ return Promise.reject(
+ Object.assign(new Error(`Cannot find package '${pkg}'`), {
+ code: "ERR_MODULE_NOT_FOUND",
+ }),
+ );
+ });
+ await expect(loadTurnkeySdk()).rejects.toThrow(/pnpm add/);
+ await expect(loadTurnkeySdk()).rejects.toThrow(/pnpm add/);
+ // Two packages per attempt × two attempts.
+ expect(calls).toBe(4);
+ });
+
+ it("setTurnkeySdkImporter(null) restores the default module source", async () => {
+ setTurnkeySdkImporter(() => Promise.reject(new Error("armed")));
+ await expect(loadTurnkeySdk()).rejects.toThrow(/armed/);
+ setTurnkeySdkImporter(null);
+ const provider = new TurnkeyWalletProvider(BASE_OPTS);
+ const result = await provider.signMessage("recovered");
+ expect(result.messageHash).toBe(hashMessage("recovered"));
+ });
+});
diff --git a/typescript/tests/turnkeyTypeCompat.test.ts b/typescript/tests/turnkeyTypeCompat.test.ts
new file mode 100644
index 0000000..06bc941
--- /dev/null
+++ b/typescript/tests/turnkeyTypeCompat.test.ts
@@ -0,0 +1,51 @@
+/**
+ * Compile-time drift detection between `src/wallets/turnkey/types.ts` (the
+ * SDK-shipped structural mirrors) and the real `@turnkey/sdk-server` /
+ * `@turnkey/viem` types (devDependencies, imported type-only so the
+ * optional peers never enter the runtime graph).
+ *
+ * The failure surface is `pnpm typecheck`, not the test runner: every
+ * function below is an assignability assertion the compiler must accept.
+ * Module/class shapes are pinned real → mirror (our mirrors are a
+ * deliberate subset of the vendor surface); the constructor config is
+ * pinned mirror → real (we build the config and feed it to the vendor).
+ */
+
+import type * as TurnkeySdkServer from "@turnkey/sdk-server";
+import type * as TurnkeyViem from "@turnkey/viem";
+import { describe, expect, it } from "vitest";
+import type {
+ TurnkeyClientConfig,
+ TurnkeySdkServerModule,
+ TurnkeyViemModule,
+} from "../src/wallets/turnkey/types.js";
+
+// ── Module shapes: the real packages must satisfy our internal subset ─────
+
+const sdkServerModuleToMirror = (
+ v: typeof TurnkeySdkServer,
+): TurnkeySdkServerModule => v;
+
+const viemModuleToMirror = (v: typeof TurnkeyViem): TurnkeyViemModule => v;
+
+// ── Constructor config: our config values feed the real constructor ───────
+
+const configToReal = (
+ v: TurnkeyClientConfig,
+): ConstructorParameters[0] => v;
+
+describe("turnkey type mirrors", () => {
+ it("stay assignable against @turnkey/sdk-server and @turnkey/viem (compile-time contract)", () => {
+ // The assignability functions above ARE the assertions; tsc rejects
+ // this file the moment the mirrors drift. Referencing them here keeps
+ // them live under lint and gives vitest a runtime anchor.
+ const witnesses = [
+ sdkServerModuleToMirror,
+ viemModuleToMirror,
+ configToReal,
+ ];
+ for (const witness of witnesses) {
+ expect(typeof witness).toBe("function");
+ }
+ });
+});