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
- Transaction sender (
bnb-sender, port 8000) — RESTPOST /send/bnb, signs a Chapel transfer, waits for inclusion, then asks the validator. - Transaction validator (
bnb-validator, port 8001) — checks the hash with Etherscan API V2 (chainid=97). Do not call deprecated BscScan V1 (api.bscscan.com). - 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. Confirmeth_chainIdis0x61(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

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/bnbhas no authentication and will spend whateverUSER_KEYcontrols. - Never expose port 8000 (or the other agent ports) to the internet. Never reuse a mainnet key. Prefer a throwaway Chapel account.
- Keep
USER_KEYout of git, logs, and screenshots.
Installation
- Create a directory and virtualenv:
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.
- Install the versions this page was tested with (
web37.x removedgeth_poa_middleware):
pip install uagents==0.25.5 web3==7.16.0 python-dotenv==1.0.0 requests
- Create
.env(placeholders only — never commit real keys):
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)
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:
{
"status": "1",
"message": "OK",
"result": {
"isError": "0",
"errDescription": ""
}
}
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().
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.
# 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):
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):
{
"response": "Successful transfer confirmed by receiver. tx_hash: 0x..."
}
The monitor should then log a Recorded transaction object for the same wallet.
System flow
- curl hits
bnb-senderon127.0.0.1:8000. - The sender signs with
USER_KEY, broadcasts, thenwait_for_transaction_receipt. - It
send_and_receives the hash tobnb-validator. Timeouts return an explicit message instead of crashing onNone. - The validator queries Etherscan V2
getstatusfor chain 97. bnb-monitorkeeps polling Chapel blocks forUSER_WALLET.
Troubleshooting
| Symptom | Likely cause | What to do |
|---|---|---|
ImportError: geth_poa_middleware | web3 7.x | Use ExtraDataToPOAMiddleware and web3==7.16.0 |
ExtraDataLengthError on get_block | Missing POA middleware | Inject ExtraDataToPOAMiddleware at layer 0 |
Explorer NOTOK / deprecated V1 | api.bscscan.com or api-testnet.bscscan.com V1 | Use https://api.etherscan.io/v2/api with chainid=97 |
| Validator always unknown | Mainnet chainid=56 or checking before inclusion | Wait for receipt first; keep chain id 97 |
TypeError / ValueError on checksum | Placeholder USER_WALLET | Fill Chapel address; validation runs at startup, not import |
curl Transaction Failed + insufficient funds | Empty faucet wallet | Fund Chapel via the faucet link above |
rawTransaction AttributeError | web3 older than 6 | Stay on web3==7.16.0 (raw_transaction) |
| Address in curl no longer works | Agents started without seed | Keep the seeds in the samples; copy Agent2 from this run's startup log |
| Etherscan plan / chain error | Free key may omit Chapel | Check supported chains; do not use V1 |
Best practices
-
Security
- Never commit
.envor pasteUSER_KEYinto issues or chat. - Keep REST on loopback. Treat
/send/bnbas an unauthenticated local demo. - Use a throwaway Chapel key only.
- Never commit
-
Error handling
- Handle validator timeout (
message is None) separately from on-chain revert. - Log tx hashes so you can inspect them on Chapel BscScan.
- Handle validator timeout (
-
Monitoring
- Confirm RPC
chainIdis 97 before leaving the monitor running. - Expect public RPC rate limits; set
BNB_TESTNET_RPCto a stable endpoint if the default rotates.
- Confirm RPC
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.