Skip to main content
Version: Next

BNB Chain Testnet (Chapel) Agents

This is the hands-on BNB Smart Chain Chapel testnet lab. Three uAgents automate a Chapel transfer: one broadcasts via REST, one confirms the hash on the explorer API, and one polls new blocks for wallet activity.

This is not an LLM / ASI:One agent. There is no model in the loop — only uAgents messaging, a local REST endpoint, and web3.

Do not use mainnet keys, mainnet RPC, or mainnet funds in this guide. Chapel is chain ID 97. Keys that ever held real BNB must stay offline.

Related reading: AI Agents Reshaping the On-Chain Ecosystem (overview) and Mettalex (production-style DEX agents).

Overview

  1. Transaction sender (bnb-sender, port 8000) — REST POST /send/bnb, signs a Chapel transfer, waits for inclusion, then asks the validator.
  2. Transaction validator (bnb-validator, port 8001) — checks the hash with Etherscan API V2 (chainid=97). Do not call deprecated BscScan V1 (api.bscscan.com).
  3. Wallet monitor (bnb-monitor, port 8002) — scans new Chapel blocks for the funded test wallet.

Prerequisites

  • Python 3.11 or higher
  • A Chapel testnet account with test BNB from the BNB Chain testnet faucet
  • An Etherscan API key (BscScan keys live under the same Etherscan dashboard after the V2 migration)
  • Chapel RPC: set BNB_TESTNET_RPC (public endpoints rotate). Default used below is documented in the BNB JSON-RPC list. Confirm eth_chainId is 0x61 (97), not mainnet 56.

Free Etherscan plans may not cover every chain. If V2 returns a plan / chain error for chainid=97, upgrade the key plan or use another Chapel-indexed explorer — do not fall back to BscScan V1.

Architecture

BNB Chapel uAgents: REST sender, chain, validator, monitor

POA middleware is required on Chapel: blocks carry extraData longer than the 32-byte geth limit. Without ExtraDataToPOAMiddleware, get_block raises ExtraDataLengthError (often ~279 bytes of extra data).

Security

  • Bind this demo to localhost only. /send/bnb has no authentication and will spend whatever USER_KEY controls.
  • Never expose port 8000 (or the other agent ports) to the internet. Never reuse a mainnet key. Prefer a throwaway Chapel account.
  • Keep USER_KEY out of git, logs, and screenshots.

Installation

  1. Create a directory and virtualenv:
setup.sh
mkdir bnb-chain-agents
cd bnb-chain-agents
python -m venv venv
source venv/bin/activate

Windows: venv\Scripts\activate instead of source venv/bin/activate.

  1. Install the versions this page was tested with (web3 7.x removed geth_poa_middleware):
requirements.sh
pip install uagents==0.25.5 web3==7.16.0 python-dotenv==1.0.0 requests
  1. Create .env (placeholders only — never commit real keys):
.env
USER_WALLET=0xYourChapelTestnetAddress
USER_KEY=0xYourChapelTestnetPrivateKey
ETHERSCAN_API_KEY=YourEtherscanApiKey
BNB_TESTNET_RPC=https://data-seed-prebsc-1-s1.binance.org:8545/

Implementation

1. Transaction sender (agent1.py)

agent1.py
import os

from dotenv import load_dotenv
from uagents import Agent, Context, Model
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware

load_dotenv()

DEFAULT_RPC = "https://data-seed-prebsc-1-s1.binance.org:8545/"
PLACEHOLDER_WALLET = {"", "your_wallet_address", "0xYourChapelTestnetAddress"}
PLACEHOLDER_KEY = {"", "your_private_key", "0xYourChapelTestnetPrivateKey"}

agent_user = Agent(
name="bnb-sender",
seed="bnb-sender-seed",
port=8000,
endpoint=["http://127.0.0.1:8000/submit"],
)

class RequestTransfer(Model):
to_address: str
amount: float
agent_to_address: str

class RequestDetails(Model):
tx_hash: str

class ResponseTransfer(Model):
response: str

def connect_chapel() -> Web3:
rpc = os.getenv("BNB_TESTNET_RPC") or DEFAULT_RPC
w3 = Web3(Web3.HTTPProvider(rpc))
# Chapel extraData exceeds geth's 32-byte cap; skip this and get_block raises ExtraDataLengthError.
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
return w3

web3 = connect_chapel()

def require_funded_account() -> tuple[str, str]:
user_wallet = (os.getenv("USER_WALLET") or "").strip()
user_key = (os.getenv("USER_KEY") or "").strip()
if user_wallet in PLACEHOLDER_WALLET:
raise ValueError("USER_WALLET is missing or still a placeholder in .env")
if user_key in PLACEHOLDER_KEY:
raise ValueError("USER_KEY is missing or still a placeholder in .env")
return web3.to_checksum_address(user_wallet), user_key

@agent_user.on_event("startup")
async def sender_startup(ctx: Context):
ctx.logger.info(f"bnb-sender address (Almanac): {agent_user.address}")
ctx.logger.info("REST is localhost-only. Do not expose /send/bnb or USER_KEY.")

@agent_user.on_rest_post("/send/bnb", RequestTransfer, ResponseTransfer)
async def handle_post(ctx: Context, req: RequestTransfer) -> ResponseTransfer:
try:
from_address, user_key = require_funded_account()
to_address = web3.to_checksum_address(req.to_address)
nonce = web3.eth.get_transaction_count(from_address)

tx = {
"to": to_address,
"value": web3.to_wei(req.amount, "ether"),
"gas": 21000,
"gasPrice": web3.eth.gas_price,
"nonce": nonce,
"chainId": 97,
}

signed_tx = web3.eth.account.sign_transaction(tx, user_key)
tx_hash = web3.eth.send_raw_transaction(signed_tx.raw_transaction)
tx_hex = web3.to_hex(tx_hash)

receipt = web3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
if receipt.status != 1:
return ResponseTransfer(
response=f"Transaction reverted on-chain. tx_hash: {tx_hex}"
)

message, status = await ctx.send_and_receive(
req.agent_to_address,
RequestDetails(tx_hash=tx_hex),
response_type=ResponseTransfer,
timeout=60,
)
if message is None:
return ResponseTransfer(
response=(
f"Broadcast succeeded but validator timed out "
f"(status={status}). tx_hash: {tx_hex}"
)
)

return ResponseTransfer(response=message.response)

except Exception as e:
return ResponseTransfer(response=f"Transaction Failed: {e}")

if __name__ == "__main__":
agent_user.run()

signed_tx.raw_transaction (snake_case) is correct on web3==7.16.0. Older web3 used rawTransaction; do not mix versions.

2. Transaction validator (agent2.py)

Etherscan API V2 unified URL: https://api.etherscan.io/v2/api with chainid=97 for Chapel. Mainnet BNB would be 56 — that will not resolve testnet hashes.

Sample successful body after a mined simple transfer:

etherscan-v2-getstatus.json
{
"status": "1",
"message": "OK",
"result": {
"isError": "0",
"errDescription": ""
}
}
agent2.py
import asyncio
import os

import requests
from dotenv import load_dotenv
from uagents import Agent, Context, Model

load_dotenv()

agent_dummy = Agent(
name="bnb-validator",
seed="bnb-validator-seed",
port=8001,
endpoint=["http://127.0.0.1:8001/submit"],
)

ETHERSCAN_V2 = "https://api.etherscan.io/v2/api"
CHAPEL_CHAIN_ID = 97

class RequestDetails(Model):
tx_hash: str

class ResponseTransfer(Model):
response: str

async def get_transaction_status(tx_hash: str) -> dict:
api_key = (os.getenv("ETHERSCAN_API_KEY") or "").strip()
if not api_key or api_key in {"your_bscscan_api_key", "YourEtherscanApiKey"}:
return {"error": "ETHERSCAN_API_KEY is missing or still a placeholder"}

params = {
"chainid": CHAPEL_CHAIN_ID,
"module": "transaction",
"action": "getstatus",
"txhash": tx_hash,
"apikey": api_key,
}
response = await asyncio.to_thread(requests.get, ETHERSCAN_V2, params=params, timeout=30)
if response.status_code != 200:
return {"error": f"HTTP error {response.status_code}"}
payload = response.json()
if str(payload.get("status")) == "0":
return {
"error": payload.get("result") or payload.get("message") or "Etherscan V2 NOTOK"
}
return payload

@agent_dummy.on_event("startup")
async def validator_startup(ctx: Context):
ctx.logger.info(f"bnb-validator address — paste this into curl as agent_to_address:")
ctx.logger.info(agent_dummy.address)

@agent_dummy.on_message(model=RequestDetails, replies={ResponseTransfer})
async def on_tx_details(ctx: Context, sender: str, msg: RequestDetails):
tx_status = await get_transaction_status(msg.tx_hash)

if "error" in tx_status:
reply = f"API Error: {tx_status['error']}"
elif tx_status.get("status") == "1" and tx_status.get("message") == "OK":
result = tx_status.get("result") or {}
if result.get("isError") == "0":
reply = f"Successful transfer confirmed by receiver. tx_hash: {msg.tx_hash}"
else:
err_desc = result.get("errDescription", "Unknown error")
reply = f"Transfer rejected: {err_desc}"
else:
reply = "Transfer status unknown or API response error."

await ctx.send(sender, ResponseTransfer(response=reply))

if __name__ == "__main__":
agent_dummy.run()

3. Wallet monitor (monitor_wallet.py)

Do not checksum the wallet at import time. A missing .env used to crash with TypeError / ValueError before run().

monitor_wallet.py
import json
import os

from dotenv import load_dotenv
from uagents import Agent, Context
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware

load_dotenv()

DEFAULT_RPC = "https://data-seed-prebsc-1-s1.binance.org:8545/"
PLACEHOLDER_WALLET = {"", "your_wallet_address", "0xYourChapelTestnetAddress"}

monitor_agent = Agent(
name="bnb-monitor",
seed="bnb-monitor-seed",
port=8002,
endpoint=["http://127.0.0.1:8002/submit"],
)

rpc = os.getenv("BNB_TESTNET_RPC") or DEFAULT_RPC
web3 = Web3(Web3.HTTPProvider(rpc))
web3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)

monitored_address = None
last_scanned_block = None

@monitor_agent.on_event("startup")
async def monitor_startup(ctx: Context):
global monitored_address, last_scanned_block
raw = (os.getenv("USER_WALLET") or "").strip()
if raw in PLACEHOLDER_WALLET:
raise SystemExit(
"USER_WALLET is missing or still a placeholder. Set a Chapel testnet address in .env"
)
try:
monitored_address = web3.to_checksum_address(raw)
except Exception as exc:
raise SystemExit(f"USER_WALLET is not a valid address: {exc}") from exc
last_scanned_block = web3.eth.block_number
ctx.logger.info(f"bnb-monitor address: {monitor_agent.address}")
ctx.logger.info(f"Watching wallet {monitored_address} from block {last_scanned_block}")

@monitor_agent.on_interval(period=10)
async def monitor_handler(ctx: Context):
global last_scanned_block

if monitored_address is None or last_scanned_block is None:
ctx.logger.error("Monitor is not initialized; check USER_WALLET on startup.")
return

try:
current_block = web3.eth.block_number
if current_block <= last_scanned_block:
return

for block_num in range(last_scanned_block + 1, current_block + 1):
block = web3.eth.get_block(block_num, full_transactions=True)
for tx in block["transactions"]:
tx_from = tx["from"]
tx_to = tx["to"]
if (
tx_from and tx_from.lower() == monitored_address.lower()
) or (tx_to and tx_to.lower() == monitored_address.lower()):
details = {
"blockNumber": block_num,
"hash": tx["hash"].hex(),
"from": tx_from,
"to": tx_to if tx_to else "Contract Creation",
"value": str(web3.from_wei(tx["value"], "ether")) + " BNB",
}
ctx.logger.info(f"Recorded transaction: {json.dumps(details, indent=2)}")

last_scanned_block = current_block

except Exception as e:
ctx.logger.error(f"Error during monitoring: {e}")

if __name__ == "__main__":
monitor_agent.run()

Usage

Start validator first so you can copy its agent1q… address. Seeds keep addresses stable across restarts.

run-agents.sh
# Terminal 1 — copy the agent1q… line from this log into curl
python agent2.py

# Terminal 2
python agent1.py

# Terminal 3
python monitor_wallet.py

Windows: py agent2.py (and the other two files) if python is not on PATH.

Send a Chapel transfer (replace the two placeholders; agent_to_address is Agent2's log line, not a 0x wallet):

send-bnb.sh
curl -sS -X POST http://127.0.0.1:8000/send/bnb \
-H "Content-Type: application/json" \
-d '{
"to_address": "0xRecipientChapelAddress",
"amount": 0.001,
"agent_to_address": "agent1q..."
}'

Happy-path HTTP body (wording may vary slightly):

curl-success.json
{
"response": "Successful transfer confirmed by receiver. tx_hash: 0x..."
}

The monitor should then log a Recorded transaction object for the same wallet.

System flow

  1. curl hits bnb-sender on 127.0.0.1:8000.
  2. The sender signs with USER_KEY, broadcasts, then wait_for_transaction_receipt.
  3. It send_and_receives the hash to bnb-validator. Timeouts return an explicit message instead of crashing on None.
  4. The validator queries Etherscan V2 getstatus for chain 97.
  5. bnb-monitor keeps polling Chapel blocks for USER_WALLET.

Troubleshooting

SymptomLikely causeWhat to do
ImportError: geth_poa_middlewareweb3 7.xUse ExtraDataToPOAMiddleware and web3==7.16.0
ExtraDataLengthError on get_blockMissing POA middlewareInject ExtraDataToPOAMiddleware at layer 0
Explorer NOTOK / deprecated V1api.bscscan.com or api-testnet.bscscan.com V1Use https://api.etherscan.io/v2/api with chainid=97
Validator always unknownMainnet chainid=56 or checking before inclusionWait for receipt first; keep chain id 97
TypeError / ValueError on checksumPlaceholder USER_WALLETFill Chapel address; validation runs at startup, not import
curl Transaction Failed + insufficient fundsEmpty faucet walletFund Chapel via the faucet link above
rawTransaction AttributeErrorweb3 older than 6Stay on web3==7.16.0 (raw_transaction)
Address in curl no longer worksAgents started without seedKeep the seeds in the samples; copy Agent2 from this run's startup log
Etherscan plan / chain errorFree key may omit ChapelCheck supported chains; do not use V1

Best practices

  1. Security

    • Never commit .env or paste USER_KEY into issues or chat.
    • Keep REST on loopback. Treat /send/bnb as an unauthenticated local demo.
    • Use a throwaway Chapel key only.
  2. Error handling

    • Handle validator timeout (message is None) separately from on-chain revert.
    • Log tx hashes so you can inspect them on Chapel BscScan.
  3. Monitoring

    • Confirm RPC chainId is 97 before leaving the monitor running.
    • Expect public RPC rate limits; set BNB_TESTNET_RPC to a stable endpoint if the default rotates.

Conclusion

These three uAgents automate Chapel transfers, explorer confirmation, and wallet watching. Extend the same pattern to contracts or other EVM testnets — still without putting mainnet keys in the process.