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

Filter by extension

Filter by extension


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

## Wallets

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

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

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

## Getting started

Expand Down
34 changes: 18 additions & 16 deletions python/bnbagent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,45 +26,47 @@

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

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

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

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

kwargs: dict[str, str] = {}
if self.wallet_kind.strip().lower() == "twak":
# Pin twak to the config's network (chain identity must not
Expand Down
22 changes: 13 additions & 9 deletions python/bnbagent/core/contract_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"),
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions python/bnbagent/core/multicall.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion python/bnbagent/erc8004/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions python/bnbagent/erc8004/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"

Expand Down
9 changes: 4 additions & 5 deletions python/bnbagent/erc8004/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion python/bnbagent/erc8183/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
27 changes: 12 additions & 15 deletions python/bnbagent/erc8183/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion python/bnbagent/erc8183/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -180,6 +180,7 @@ def from_env(

if storage is None:
from ..storage import LocalStorageProvider

storage = LocalStorageProvider.from_env()

return cls(
Expand Down
Loading
Loading