Agent Gateway: keyless programmatic access to 2D
An autonomous agent that moves funds on someone’s behalf (a payment bot, a treasury automator, an LLM) needs two things from a chain: a way to read state, and a key to sign transactions. Reading is the easy half. The key is where the risk lives.
A signing key kept inside an agent process is one of the softest targets in a crypto system. Agents consume untrusted input on purpose: prompts, web pages, quotes from other parties. A prompt injection or a phished config is enough to make the key sign whatever an attacker wants. And because the key sits inside the agent, on-chain policy is the only layer left between the attacker and the funds. The track record of agent-style automation in crypto is, mostly, a list of drained hot wallets.
2D’s Agent Gateway keeps the key out of the agent. An agent never holds a private key. It holds a bearer token that authorizes a fixed set of operations on one assigned wallet, while the wallet’s signing key lives in a separate 2d-hsm enclave running inside a confidential VM. The gateway talks to the enclave over a Unix-domain socket; the agent talks to the gateway over authenticated HTTP. Agent-initiated writes are checked and signed inside the enclave under operator policy, never by the agent itself.
This article covers what the gateway exposes, the authorization model, how an assigned wallet is bound to a key held in a TEE, the proof-of-possession check that confirms the key is still held by the enclave, and what an operator has to switch on before any of it moves funds.
The surface
Section titled “The surface”The gateway is available at https://poa.net/77/agent/v1 on the POA production network, chain 77. Wallet operations require a bearer token and capability and pass IP and agent rate limits. The exception is POST /agent/v1/register: public issuance of a new token and empty assigned wallet, with separate registration quotas. Registration does not sign a payment or credit funds. Legacy GET /faucet is not published.
| Endpoint | Capability | Purpose |
|---|---|---|
GET /agent/v1/network | read | Discovery. Returns the API version, chain metadata, the agent’s own identity and capabilities, the RPC method allowlist, and the rate limits in effect. Call this first. |
GET /agent/v1/balance | read | The assigned wallet’s balance, in token base units (USDC, 6 decimals) plus an 18-decimal form for eth_getBalance parity. This is the authoritative balance for the agent’s own wallet. |
POST /agent/v1/rpc | read | A read-only JSON-RPC proxy. Accepts a single request or a batch. Methods are restricted to a fixed allowlist. |
POST /agent/v1/faucet | faucet | Pays the assigned wallet from the faucet treasury, signed in the enclave. Off by default. |
POST /agent/v1/x402/authorize | transfer | Authorizes an x402 payment from the assigned wallet under quote and signer policy checks. |
POST /agent/v1/transfers | transfer | Sends from the assigned wallet to a recipient, signed in the enclave. Off by default. |
The x402 demo and facilitator are a separate public surface. Assigned wallets use the gateway authorization route above with transfer capability. Unpaid GET https://poa.net/77/x402/demo returns 402. The asset is the NativeTransferAuth precompile, not Ethereum mainnet USDC. See x402.
Separate payment controls
Section titled “Separate payment controls”Registration does not enable spending. Faucet, transfer and x402 each have a separate switch, defaulting to disabled, in addition to AGENT_GATEWAY_ENABLED and the required token capability:
| Surface | Enablement and payment limits | Retry and recovery boundary |
|---|---|---|
| Faucet | AGENT_FAUCET_ENABLED; operator sets AGENT_FAUCET_PAYOUT_AMOUNT, AGENT_FAUCET_COOLDOWN_MS and AGENT_FAUCET_DAILY_LIMIT. Pays only the caller’s assigned wallet; public tokens have no faucet capability. | Required idempotency_key; replays reuse the recorded result, changed requests conflict. FundingGate checks both treasury and recipient, backup/identity and applicable restore evidence or pre-restore ceilings. |
| Transfer | AGENT_TRANSFER_ENABLED; AGENT_TRANSFER_PER_TX_LIMIT and AGENT_TRANSFER_DAILY_LIMIT default to zero. Optional agent_transfer_recipient_allowlist narrows recipients. | Required idempotency_key, scoped to agent and action kind; changed payloads conflict. FundingGate checks the source wallet and applicable restore evidence or pre-restore wallet ceiling. |
| x402 authorization | AGENT_X402_ENABLED; exact amount and recipient are pinned by AGENT_X402_AMOUNT and AGENT_X402_PAY_TO. AGENT_X402_MAX_TIMEOUT_SECONDS bounds validity (default 180 seconds). Requires live Gateway signing in production. | Idempotency is (agent_id, kind, idempotency_key), default key x402:<nonce>; changed requests conflict. The key-assignment fence and signer policy apply, but the faucet/transfer FundingGate, restore-evidence ceilings and transfer daily quotas do not apply. |
For faucet and transfer, pre-restore ceilings resolve in zero_only mode unless an explicit approved_non_zero exception has matching active local approval. A time-limited exception is not evidence of completed disaster recovery. x402 is a separately reviewed financial surface: disabling transfers or reducing transfer quotas does not disable x402 or cap its cumulative spend. Review x402 signer policy and recovery readiness independently before enabling it. The HTTP resource URL is bound in the gateway request hash, not in the cryptographic payment authorization.
The RPC proxy is not a pass-through. It enforces a closed method allowlist, enumerated in the RpcProxy module:
eth_chainId, eth_blockNumber, eth_getBalance,eth_getTransactionByHash, eth_getTransactionReceipt, eth_getBlockByNumberAny other method is rejected with a JSON-RPC error that includes the allowlist. There is no eth_sendRawTransaction, no eth_call against a write entry point, and no way to submit a state-changing transaction through /rpc. Agent writes go through the dedicated /faucet and /transfers endpoints and are signed in the enclave; raw RPC is never a write path, whatever the agent’s capabilities. Batches are capped at 100 requests and 64 KB on the wire, so one agent cannot hold a connection open with an oversized batch.
A discovery request is the recommended first call:
curl -s -H "Authorization: Bearer $AGENT_TOKEN" \ https://poa.net/77/agent/v1/network | jq .The response describes the chain (id 77, native token USDC with 6 decimals, latest block), the calling agent (agent_id, assigned_wallet, capabilities), the RPC allowlist, and the current rate limits. Treat /network as the contract: an agent that reads it can adapt to the exact methods and limits in force, without hardcoding them.
One deliberate asymmetry. /balance returns only the assigned wallet’s balance. eth_getBalance inside /rpc accepts any address. Read-only inspection of the whole network (any address’s balance, any confirmed transaction’s receipt) is a read operation, and a :read-capable agent may perform it. State changes are not.
Public token registration
Section titled “Public token registration”Use Create API token. No invitation or email is required when registration is enabled. The token is returned once; only its secret hash is stored. Each account receives a distinct verified, backed-up enclave key and fixed read and transfer capabilities. The client cannot supply a wallet, capability or token secret.
Registration is disabled in configuration and paused in the initial database policy. Once enabled, defaults are 3 successful registrations per IP per UTC day, 100 globally per UTC day, a lifetime ceiling of 1,000 and a reserve of 10 verified keys. Attempts are additionally capped at 10 per IP and 120 globally per minute. IPv6 is grouped by /64; IP quotas are an abuse budget, not proof of a unique person. Global ceilings bound distributed abuse.
Issuance quotas and key assignment commit together under a database lock. Failed provisioning still consumes the separate attempt budget. Empty or reserved pools return 503; rate limits return 429 with Retry-After. A lost HTTP response can leave an allocated wallet without a delivered token, so clients must not retry automatically.
The private operator console shows issuance, rejections, capacity, recent accounts and node-local API usage. It can pause registration, edit quotas and disable an account without recycling its key. Public Explorer routes never expose the admin API.
Authorization: token, capability, rate limit
Section titled “Authorization: token, capability, rate limit”Every wallet API request authenticates against an agent identity: a database row holding a token identifier and secret hash, an assigned_wallet, an enabled flag, and a set of capabilities. Capabilities form a closed enum of exactly three: read, faucet and transfer. Each is checked by its own route pipeline, so an agent granted read cannot reach a write endpoint no matter how it is called. Requesting an unknown capability is rejected as a changeset error at provisioning time, not silently ignored.
The authentication plug (AgentAuth) fails closed. Invalid or revoked tokens and disabled identities return 401 agent_auth_failed. A valid token missing the required capability returns 403 agent_capability_required. A disabled gateway returns a separate 404.
Rate limiting has two layers, each shaped to a different kind of abuse. Both are implemented as an ETS sliding window in AgentRateLimit:
- Pre-authentication, per IP: 60 authentication failures per 60 seconds. Defends against token brute force. Only failed authentications are counted, so a legitimate client making many requests after one successful auth is not penalized.
- Post-authentication, per agent: 600 requests per 60 seconds. Bounds a runaway client. It limits how fast a single identity can drive the gateway, regardless of how many IPs it comes from.
Both layers return standard 429 responses. The pre-auth bucket is keyed by originating IP, so behind an L7 reverse proxy the gateway must be told the proxy’s CIDR allowlist (AGENT_TRUSTED_PROXIES). Without it, every request looks like it comes from the proxy’s address, and the pre-auth limit collapses into one shared bucket. This is the same proxy/CIDR reasoning the bridge operator endpoint uses; the two sit on separate environment variables for separate surfaces. The public production URL is HTTPS. A bearer token sent over plaintext HTTP can be intercepted, which would undermine the trust model described below.
The whole gateway has a master switch. If AGENT_GATEWAY_ENABLED is unset or anything other than the literal "true", the endpoints return 404 and the background workers do not start. The default is off.
The assigned wallet and the 2d-hsm enclave
Section titled “The assigned wallet and the 2d-hsm enclave”Each agent identity has exactly one assigned_wallet: a 20-byte EVM address, derived the same way as every other 2D address (see Addresses). What makes it assigned is that its private key is generated and held by the 2d-hsm enclave, a secp256k1 signer running inside a confidential VM. This is a separate signer from the bridge operator’s signing role described in HSM topology. The agent never sees the key. The gateway never sees the key. The key exists only inside the enclave.
The enclave is a small Rust program that speaks a CBOR-over-framing protocol over a Unix-domain socket. In production the gateway connects to a host-side socket (AGENT_SIGNER_2D_HSM_ENDPOINT, a unix:// path); a host-side proxy terminates the enclave’s vsock transport. The gateway pins the connection to the agent_gateway profile and rejects any other. The protocol carries a capability object for every privileged operation, domain-separated under 2d-hsm/agent-cap/v1. The enclave enforces authority tiers on its side: generating new keys and exporting encrypted backups are admin-tier operations, restoring a backup is recovery-tier, and financial scope (treasury configuration) is constrained to be enclave-bound. Fleet-wide spend authority is refused by construction: no capability shape authorizes it.
Assigned wallets come from a transfer-key pool. The enclave generates keys in batches; each batch is exported to encrypted backup and verified before it becomes available. Provisioning claims one available key per new identity in a single transaction in IdentityProvisioner:
SELECT … FOR UPDATE OF k SKIP LOCKED LIMIT 1So two concurrent provisionings always claim distinct keys, never the same row. The identity is inserted with assigned_wallet and agent_transfer_key_ref (a 64-character hex handle into the enclave’s keystore) taken from the claimed key, and the key is flipped to assigned in the same transaction. An empty pool returns :agent_key_pool_exhausted; a pool whose rows are all locked by in-flight claims returns :agent_key_pool_busy. In neither case is an identity created. Production identities are provisioned only through this path. Public registration calls this same provisioner with server-selected fields. Hand-written identity rows remain subject to the assignment invariant.
The pool is kept warm by a RefillWorker that tops it up toward a configured target, a BatchSweeper that fails batches stuck in an intermediate backup state so their inputs get reconciled, and a BackupSink that writes each batch through a temp file, an atomic rename, and a re-read SHA-256 check before the batch advances. Key material never moves without an encrypted-backup envelope and a verified second copy.
Proof of possession
Section titled “Proof of possession”A bearer token on its own carries all of the agent’s authority: whoever steals it can act as the agent until it is revoked. The gateway pairs the token with a cryptographic check that the enclave really holds the private key behind the assigned wallet. This is the identity proof, a proof-of-possession signature the enclave produces and the gateway verifies on a schedule.
The signed preimage is domain-separated so it can never be a valid transaction or a valid signature for any other purpose. It is built under the EIP-191 0x19 signed-data prefix (the byte EIP-2718 deliberately leaves unassigned, to keep it disjoint from typed transactions), with the label 2d-hsm/agent-identity-proof/v1, and binds four fields:
- the deployment chain id (
77), - an environment identifier naming the deployment the enclave keystore was built for,
- the wallet’s uncompressed public key (65-byte SEC1, verified on-curve),
- and the address (20 bytes), which must match the address derived from the public key.
The enclave signs the keccak256 preimage with secp256k1 under EIP-2 low-S. The verifier recovers the address from the signature and compares it to the public key; a proof that does not verify against the stored key is rejected. The full construction is in IdentityProof.
What the proof establishes is key custody, not caller identity. It runs on a schedule, not on every request, so it does not stop a stolen bearer token from authorizing reads until the operator revokes the token. What it does prevent is different: no party except the enclave can make the gateway believe a wallet’s key is still healthy. The token authorizes reads; only the enclave can attest that the key behind those reads is still the one the operator provisioned.
Live verification and the sweep
Section titled “Live verification and the sweep”The gateway ships dormant by default. With AGENT_SIGNER_LIVE_IDENTITY_ENABLED unset, identity calls go to a stub that returns :agent_signer_protocol_unavailable for every request. This is deliberate: it lets the read surface and the background workers run against an unwired signer without disabling anything. The IdentitySweep runs in both dormant and live modes. Under the dormant stub every identity falls into the :terminal_unknown class (below), which leaves wallets intact, which is why dormancy disables nothing.
Flipping to live selects SignerClient.Live, which performs the real proof-of-possession exchange. The flip is gated by a mandatory canary that runs the same configuration the node will run under and reports whether the enclave verifies one assigned key:
- exit
0: identity verified, configuration matches the deployment, safe to flip; - exit
1: no candidate key to probe (populate the key first); - exit
2: do not flip. Either a configuration or wire mismatch, or an inconclusive transient.
The canary is a probe: it makes no database mutation, so even a mismatch verdict leaves the probed key assigned. The gate exists because the two failure modes that matter are both configuration-driven. An unset chain id or environment identifier under Live turns into a terminal-config finding on every sweep tick: noise that verifies nothing. Worse, a wrong-but-in-range chain id classifies as destructive. The signed proof will not verify, and the next sweep would disable every stale assigned wallet. That outcome is fail-closed (keys disabled, not drained; recovery is to fix the config and re-enable), but operationally severe, which is why the canary is mandatory.
Once live, an IdentitySweep running on one designated instance periodically re-runs the proof for every enabled identity whose last verification is older than the configured window. Every outcome is classified, and only one class changes state:
| Class | What it means | Action |
|---|---|---|
:destructive | The key no longer verifies against the stored identity: key-ref mismatch, pubkey/address mismatch, a failed proof | The key is disabled. No retry. |
:retryable | Transient transport failure reaching the enclave | Finding recorded; wallet intact; retried next tick. |
:terminal_config / :terminal_wire / :terminal_agent / :terminal_malformed | Misconfiguration, protocol mismatch, malformed response | Finding recorded every tick; wallet intact. |
:terminal_unknown | Unexpected raise, exit, or throw; the dormant stub’s unavailable response | Finding recorded; wallet intact. |
:destructive is the only class that disables a wallet. Every other class preserves the pre-flip posture: a transient outage or an unwired signer must not take agents offline. The disable is idempotent (a repeat sweep over an already-disabled key is a no-op), and recovery is to fix the cause and re-enable the key through the operator write path.
Audit and idempotency scaffolding
Section titled “Audit and idempotency scaffolding”Faucet, transfer and x402 authorization all record an agent_actions row before signing, with uniqueness on (agent_id, kind, idempotency_key). Faucet and transfer require an explicit key; x402 uses kind :x402 and defaults to x402:<nonce>. Changed payloads with the same key conflict.
Faucet and transfer proceed through requested → signed → submitted → confirmed, with terminal error states. x402 authorization returns and replays the stored signed payment payload; settlement is a separate facilitator operation, so the gateway authorization row does not use transfer submission/confirmation or transfer quotas. Its key fence is not the faucet/transfer FundingGate or restore-ceiling check. See x402 for quote, nonce and replay constraints. Registration has no such replay contract and must never be retried automatically after a lost response.
Trust model summary
Section titled “Trust model summary”The gateway’s safety rests on three separable properties:
- The agent holds a token, not a key. Every state-changing signature is produced inside the 2d-hsm enclave under operator-defined capability policy, never by the agent. Compromising the agent process yields a bearer token valid only for the operations that token allows, and only until it is revoked. Revocation is a per-identity database flag checked on every request, so a revoked token stops working on the next request after the operator sets it. Propagation is one request, not a cache TTL.
- The token is paired with a proof of possession. Re-verification confirms that the enclave still holds the key behind the assigned wallet. A token on its own cannot confirm this, and a mismatch disables the wallet fail-closed.
- The surface is closed, bounded, and off until armed. A fixed method allowlist, a 64 KB / 100-request batch cap, and a two-layer rate limit bound what any single agent can do to the node. The gateway as a whole defaults to off; faucet, transfer and x402 default to off independently of it; transfer limits default to zero; faucet/transfer pre-restore ceilings require explicit, time-limited approval for non-zero amounts. x402 has separate pinned amount/recipient and validity policy, not transfer daily quotas. Each of those is a separate decision an operator has to take, and none of them defaults toward permitting a payment.
The keys live on the same hardware/TEE substrate family as the rest of the signing infrastructure. The assigned-wallet keys occupy their own namespace in the 2d-hsm keystore, separate from the bridge operator’s keys. On-chain rules treat an assigned wallet like any other account: the security model and state-root guarantees apply to it unchanged.
Deployment evidence
Section titled “Deployment evidence”Chain 77 is production. Code defaults, active deployment settings and completed recovery ceremonies are separate facts. Check the deployed release, signed restore evidence and current signer canary before changing funding or signing policy. This article does not attest that a particular restore ceremony has or has not happened.
Public registration never grants faucet. Its transfer capability permits both transfers and x402 authorization when their respective switches and policies allow them. Losing a token can make its wallet inaccessible; do not fund it before saving the token. Assigned keys are not recycled after a timeout or account disablement.
Where this fits in the rest of the chain
Section titled “Where this fits in the rest of the chain”The gateway is a consumer-facing surface layered on the same chain the rest of the documentation describes. The addresses article explains the 20-byte account an assigned wallet resolves to. The security model covers the trust boundaries the gateway sits behind. The HSM topology article covers the bridge operator’s separate signer path; the 2d-hsm enclave the gateway uses is a distinct signer in the same hardware family, and the long-term direction for that enclave is the PQ signing in the TEE design.