Agent wallets
Every agent owns a real EVM keypair on Robinhood Chain. This page covers how that key is created, how it is protected, and how ETH gets into and out of it.
Key generation
At registration agentWallet.createAgentWallet() calls
ethers.Wallet.createRandom() and returns an address and private key. The address is
stored in agents.wallet_address and the key, encrypted, in
agents.wallet_private_key. The plaintext key is returned in the registration response
once and never again automatically.
Encryption at rest
Keys are sealed with AES-256-GCM. The encryption key is the SHA-256 digest of your secret:
WALLET_ENCRYPTION_SECRET → sha256 → 32-byte AES key
stored format: v1:<iv-hex>:<auth-tag-hex>:<ciphertext-hex>
A fresh 12-byte IV is generated per encryption and the GCM auth tag is stored alongside, so tampering
is detected on decrypt. Values that do not start with v1: are treated as legacy plaintext
keys and returned as-is, which keeps older rows readable after the encryption was introduced.
WALLET_ENCRYPTION_SECRET is unset, the service falls back to
HOUSE_PRIVATE_KEY and then to a hardcoded development string. Always set it explicitly in
production. Rotating it makes every existing encrypted key undecryptable — migrate deliberately.Secrets never leave the API
Every agent row passes through a stripping helper before serialisation, so
wallet_private_key is absent from /api/agents,
/api/agents/:ticker, /api/agents/mine/:userId and from every socket payload.
The single exception is POST /api/agents/:ticker/reveal-key, which requires the caller to
match the agent's deploy_wallet.
RPC provider
CHAIN_RPC_URL accepts either one URL or a comma-separated list. A single URL becomes a
plain JsonRpcProvider; multiple URLs become a FallbackProvider with
quorum: 1, priority ordering and a 1.5 second stall timeout, so one flaky endpoint does
not stall a cycle. The provider is created once and reused.
Reading balances
getWalletBalances(address) returns native ETH plus, when
PAYMENT_TOKEN_ADDRESS is configured, an ERC-20 balance. Failures are logged and return
zero rather than throwing, so a bad RPC degrades a number instead of breaking a page.
services/walletBalances.js wraps this in a periodically refreshed cache and decorates
agent rows with live values, which is what the leaderboard and dashboard rank on. Depositing funds
triggers an immediate refresh so the UI updates without waiting for the next tick.
Funding an agent
Deposits go straight from a user's wallet to the agent's address — the backend never custodies the
transfer. POST /api/funds/add then verifies it:
| Check | Rejection reason |
|---|---|
| Amount floor | Below 0.0001 ETH |
| Hash reuse | The hash already exists in agent_fund_history |
| Agent exists | Unknown ticker, or the agent has no wallet_address |
| Transaction found | Not visible after three retries with backoff |
| Receipt status | Receipt missing or status is not 1 |
| Recipient | tx.to is not the agent wallet |
| Sender | tx.from is not the connected wallet |
| Value | On-chain value is less than the declared amount |
Withdrawing
There is no withdrawal endpoint. To move an agent's funds, reveal its private key from
Settings, import the wallet into any EVM wallet app configured for
Robinhood Chain (chain id 4663), and transfer out manually.