Skip to content

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 gateway is mounted at /agent/v1. Every route applies, in order: JSON-only input, a pre-authentication per-IP rate limit, bearer-token authentication with a capability check, and a per-agent post-authentication rate limit. Read and write routes run on separate pipelines, because they demand different capabilities.

EndpointCapabilityPurpose
GET /agent/v1/networkreadDiscovery. 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/balancereadThe 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/rpcreadA read-only JSON-RPC proxy. Accepts a single request or a batch. Methods are restricted to a fixed allowlist.
POST /agent/v1/faucetfaucetPays the assigned wallet from the faucet treasury, signed in the enclave. Off by default.
POST /agent/v1/transferstransferSends from the assigned wallet to a recipient, signed in the enclave. Off by default.

Both write endpoints ship disabled, and that is the intended resting state rather than an implementation gap. agent_faucet_enabled and agent_transfer_enabled each default to false, and a request to a disabled endpoint is refused before anything is written to the audit trail. Arming them is an operator decision per deployment.

The transfer limits are built the same way. agent_transfer_per_tx_limit and agent_transfer_daily_limit both default to 0, so an operator who enables the endpoint without also setting limits gets a surface that refuses every transfer rather than one that permits any size. An optional recipient allowlist narrows it further.

Both write paths carry a second, independent brake. Until a production restore-drill evidence bundle exists, the pre-restore ceilings resolve in zero_only mode: a non-zero ceiling is honoured only under approved_non_zero with a matching active local approval. Disaster recovery has to be demonstrated before the endpoints can move a meaningful amount, not merely promised.

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_getBlockByNumber

Any 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:

Terminal window
curl -s -H "Authorization: Bearer $AGENT_TOKEN" \
http://localhost:4000/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.

Authorization: token, capability, rate limit

Section titled “Authorization: token, capability, rate limit”

Every gateway request authenticates against an agent identity: a database row holding a bearer token, 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) is fail-closed. A request is accepted only if the token verifies against a non-revoked, enabled identity that holds the capability the route requires. Any other outcome (bad token, revoked token, disabled identity, missing capability) returns the same generic authentication failure. The error shape is identical across all causes, so an attacker cannot tell which check failed. A gateway that has been switched off returns a separate 404 (see the master switch below), not an authentication failure.

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 localhost curl examples above are illustrative. In production, terminate TLS in front of the gateway: 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 NetHSM appliance 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 1

So 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. There is no HTTP endpoint that creates them, and hand-writing rows is rejected by 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.

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.

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:

ClassWhat it meansAction
:destructiveThe key no longer verifies against the stored identity: key-ref mismatch, pubkey/address mismatch, a failed proofThe key is disabled. No retry.
:retryableTransient transport failure reaching the enclaveFinding recorded; wallet intact; retried next tick.
:terminal_config / :terminal_wire / :terminal_agent / :terminal_malformedMisconfiguration, protocol mismatch, malformed responseFinding recorded every tick; wallet intact.
:terminal_unknownUnexpected raise, exit, or throw; the dormant stub’s unavailable responseFinding 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.

The gateway records every agent write action as an agent_actions row before signing, keyed by a client-supplied idempotency key. The state machine runs requested → signed → submitted → confirmed, with rejected, failed, and expired as terminal exits. A second request with the same idempotency key returns the original result instead of creating a new action: a replay after submitted does not re-submit, and a replay after failed does not auto-retry.

Both write endpoints run through this. An idempotency key is mandatory on /faucet and /transfers, and a request without one is rejected as a validation error before an action row is created. That ordering matters for an agent: a retry after a network timeout is safe, because the second call resolves to the first call’s outcome rather than signing again.

The gateway’s safety rests on three separable properties:

  1. 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.
  2. 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.
  3. 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; both write endpoints default to off independently of it; transfer limits default to zero; and the pre-restore ceilings stay at zero until a restore drill has actually been performed. 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.

The following are explicitly not shipped and are not documented as available:

  • Restore-drill execution. The backup, baseline, and audit-event tables exist, and a RestoreAuditMonitor continuously exports a redacted findings snapshot plus a SHA-256 digest for disaster-recovery evidence, but the production key-restore ceremony has not been performed. Provenance enforcement stays off until it is, and the pre-restore ceilings described above stay at zero.
  • Live identity verification against a production enclave. The proof-of-possession exchange is implemented and gated behind a canary, but a deployment that has not run that canary is running the dormant stub.

A note on the two faucets, because the names collide. POST /agent/v1/faucet is the authenticated agent endpoint described above, signed in the enclave and off by default. The chain’s legacy GET /faucet is a different, unauthenticated surface with no relationship to agent identities. They are not interchangeable.

This section is here on purpose. A doc that advertised an unimplemented endpoint as available would be worse than no doc, and the same applies in reverse: an endpoint that exists but is switched off should be described as exactly that.

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.