You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Agent identity: let an agent hold its own faucet credential (ERC-8004, x402, reputation)
#286
We shipped self-serve API keys so scripts and AI agents could skip the captcha,
and were about to announce it as agent support. On review it isn't.
A key that a human mints in a browser and pastes into a config file is the same
category of thing as the human fetching the tokens and forwarding them. The
frequency changed; the shape didn't.
The cause is specific: the faucet's only notion of identity is a GitHub
account, which is a human identity. A key is minted behind a next-auth session
(apps/web/pages/api/keys/index.ts), bound to the GitHub identity hash
(apps/web/utils/session-owner.ts), and drawn from that human's allowance. The
agent never holds a credential of its own — only a bearer copy of its
operator's. So the faucet cannot tell one agent from another, cannot tell an
agent from the human, and learns nothing about an agent that behaves well over
time.
Both missing pieces already exist on Celo, run by Celo:
ERC-8004 Identity Registry — Celo Sepolia 0x8004A818BFB912233c491871b3d84c89A494BD9e, mainnet 0x8004A169FB4a3325136EB29fA0ceB6D2e539a432. Gives an agent its own wallet,
owner and reputation record.
Celo's x402 facilitator — https://api.x402.celo.org. Live, and its own
onboarding is exactly the pattern we need: sign a message with your wallet,
no gas, no transaction.
Opening this before writing code, per CONTRIBUTING.md, because it changes the
auth model.
Phase 1 — agent-native key issuance via ERC-8004
A second route to a key that never touches a browser. The GitHub path stays
unchanged for humans.
POST /api/keys/challenge{ agentId } → read getAgentWallet(agentId),
return a nonce + expiry held in Redis, 5-minute TTL.
Agent signs the challenge with its agent wallet.
POST /api/keys/agent{ agentId, signature } → verify the recovered
address matches getAgentWallet(agentId), consume the nonce, mint a cfk_
key bound to agent:<chainId>:<agentId>.
Cheap to build: viem is already a dependency and apps/web/utils/firebase.serverside.ts:286-299 already builds public clients
and does readContract against a mainnet contract. Reading the registry is the
same pattern with a different address and ABI. No new dependency.
The central change is session-owner.ts, where the owner becomes a
discriminated union — { kind: 'github', hash } or { kind: 'agent', chainId, agentId }. Everything else follows from it.
Details worth agreeing on:
Require mainnet registration. Sepolia registration needs Sepolia gas,
which needs the faucet — circular.
If getAgentWallet returns the zero address, reject rather than falling back
to ownerOf. That's the NFT holder, possibly a human or a multisig, and the
whole point is that the agent acts as itself.
SIWE-shaped message (domain, chain ID, expiry) so a signature can't be
replayed against another service.
Nonce single-use, deleted before minting — we already have prior art for
this bug class in apps/web/tests/mint-race.test.ts.
On sybil resistance, honestly
addressCanBeElevatedToTrusted is the wrong tool here. It requires ≥0.01 ETH on
eth-mainnet or ≥100 LockedCELO, so it only recognises agents that already have
an economic footprint, and nobody stakes CELO to use a faucet.
Registration alone isn't a strong gate either — Celo mainnet gas is a fraction
of a cent. What bounds the damage is the ceiling we already have: PROGRAMMATIC_GLOBAL_DAILY_LIMIT (200/day) and PROGRAMMATIC_BURST_LIMIT
(20/10min) cap total programmatic payout regardless of how many identities
exist. Worst case is unchanged from today. Phases 2 and 3 are what turn this
from "capped" into "costly".
Phase 2 — x402 as the escalation
Worth noting a correction to how this is usually described: the facilitator's
API key belongs to the seller, not the buyer. The docs are explicit — "a
server-side secret. Never ship it to the browser or expose it to buyers." So:
The faucet is the seller. Connect a wallet once, sign a message, hold X402_API_KEY server-side.
The agent is the buyer. It needs no facilitator key at all — just a wallet
and a PAYMENT-SIGNATURE header. A lower bar than it first appears.
Cost to run: new accounts get free credits, then $0.001 per settlement. At our
200/day programmatic ceiling that's $0.20/day worst case, which removes the
treasury objection that made this a "someday" item in #272.
One design point that's easy to get wrong: settle on mainnet. Paying testnet
USDC for testnet CELO is money shuffling with zero sybil resistance, and the
agent would need a faucet for the USDC anyway. The charge has to land on eip155:42220 with real USDC. Price the toll around $0.01–0.10: trivial for an
honest agent, meaningful at sybil scale.
Integration note: the documented sample uses @x402/express middleware. We're
Pages Router, so wire @x402/core/server + @x402/evm/exact/server into the
route handler directly.
Phase 3 — write reputation back
This is the part that makes the cost portable, and the most interesting thing
here.
The faucet is a third party to every agent, so it can call giveFeedback on the
Reputation Registry. Both directions matter:
Misuse → negative feedback. The cost stops being local. Rotating an API
key doesn't escape it; the signal travels with the agent's identity to every
other service that reads the registry.
Good use → positive feedback. The faucet is often an agent's first
interaction with Celo, which makes it unusually well placed to bootstrap an
agent's reputation. That's a real reason to register, and it closes the loop
with phase 2: a first-time agent pays the toll, an agent with a clean record
stops paying.
Constraints this has to respect, and I'd want agreement on these specifically:
Mechanical triggers only, never judgement. Negative feedback on a public
registry is a real harm to a third party. Trigger on objective, logged events
— sustained burst-limit violations, repeated requests for addresses that
immediately sweep. Not on a hunch, and not on every 429.
Asynchronous, always. A reputation write must never block or fail a
payout. Queue it, the way payouts already go through the RTDB queue.
Correctable.revokeFeedback exists; build the revoke path and a
documented appeal route at the same time as the write path, not later.
New operational surface. The faucet needs a signer wallet and gas to write
feedback. Key management, funding and monitoring are real work, and are the
main reason this is phase 3 rather than phase 1.
Questions
Any objection to phase 1 as the shape of agent auth, or a preference for a
different identity primitive?
Phase 3 is the part I'd most like scrutiny on. Is the faucet a reputation
source we want to be, and are mechanical triggers plus a revoke path enough
of a safeguard?
Anything that should block announcing phase 1 once it ships?
Related: #272 (the original API-key design, still open), #284 / #285 (GEO pass,
independent of this).
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
The problem
We shipped self-serve API keys so scripts and AI agents could skip the captcha,
and were about to announce it as agent support. On review it isn't.
A key that a human mints in a browser and pastes into a config file is the same
category of thing as the human fetching the tokens and forwarding them. The
frequency changed; the shape didn't.
The cause is specific: the faucet's only notion of identity is a GitHub
account, which is a human identity. A key is minted behind a next-auth session
(
apps/web/pages/api/keys/index.ts), bound to the GitHub identity hash(
apps/web/utils/session-owner.ts), and drawn from that human's allowance. Theagent never holds a credential of its own — only a bearer copy of its
operator's. So the faucet cannot tell one agent from another, cannot tell an
agent from the human, and learns nothing about an agent that behaves well over
time.
Both missing pieces already exist on Celo, run by Celo:
0x8004A818BFB912233c491871b3d84c89A494BD9e, mainnet0x8004A169FB4a3325136EB29fA0ceB6D2e539a432. Gives an agent its own wallet,owner and reputation record.
https://api.x402.celo.org. Live, and its ownonboarding is exactly the pattern we need: sign a message with your wallet,
no gas, no transaction.
Opening this before writing code, per
CONTRIBUTING.md, because it changes theauth model.
Phase 1 — agent-native key issuance via ERC-8004
A second route to a key that never touches a browser. The GitHub path stays
unchanged for humans.
POST /api/keys/challenge{ agentId }→ readgetAgentWallet(agentId),return a nonce + expiry held in Redis, 5-minute TTL.
POST /api/keys/agent{ agentId, signature }→ verify the recoveredaddress matches
getAgentWallet(agentId), consume the nonce, mint acfk_key bound to
agent:<chainId>:<agentId>.Cheap to build:
viemis already a dependency andapps/web/utils/firebase.serverside.ts:286-299already builds public clientsand does
readContractagainst a mainnet contract. Reading the registry is thesame pattern with a different address and ABI. No new dependency.
The central change is
session-owner.ts, where the owner becomes adiscriminated union —
{ kind: 'github', hash }or{ kind: 'agent', chainId, agentId }. Everything else follows from it.Details worth agreeing on:
which needs the faucet — circular.
getAgentWalletreturns the zero address, reject rather than falling backto
ownerOf. That's the NFT holder, possibly a human or a multisig, and thewhole point is that the agent acts as itself.
replayed against another service.
this bug class in
apps/web/tests/mint-race.test.ts.On sybil resistance, honestly
addressCanBeElevatedToTrustedis the wrong tool here. It requires ≥0.01 ETH oneth-mainnet or ≥100 LockedCELO, so it only recognises agents that already have
an economic footprint, and nobody stakes CELO to use a faucet.
Registration alone isn't a strong gate either — Celo mainnet gas is a fraction
of a cent. What bounds the damage is the ceiling we already have:
PROGRAMMATIC_GLOBAL_DAILY_LIMIT(200/day) andPROGRAMMATIC_BURST_LIMIT(20/10min) cap total programmatic payout regardless of how many identities
exist. Worst case is unchanged from today. Phases 2 and 3 are what turn this
from "capped" into "costly".
Phase 2 — x402 as the escalation
Worth noting a correction to how this is usually described: the facilitator's
API key belongs to the seller, not the buyer. The docs are explicit — "a
server-side secret. Never ship it to the browser or expose it to buyers." So:
X402_API_KEYserver-side.and a
PAYMENT-SIGNATUREheader. A lower bar than it first appears.Cost to run: new accounts get free credits, then $0.001 per settlement. At our
200/day programmatic ceiling that's $0.20/day worst case, which removes the
treasury objection that made this a "someday" item in #272.
One design point that's easy to get wrong: settle on mainnet. Paying testnet
USDC for testnet CELO is money shuffling with zero sybil resistance, and the
agent would need a faucet for the USDC anyway. The charge has to land on
eip155:42220with real USDC. Price the toll around $0.01–0.10: trivial for anhonest agent, meaningful at sybil scale.
Integration note: the documented sample uses
@x402/expressmiddleware. We'rePages Router, so wire
@x402/core/server+@x402/evm/exact/serverinto theroute handler directly.
Phase 3 — write reputation back
This is the part that makes the cost portable, and the most interesting thing
here.
The faucet is a third party to every agent, so it can call
giveFeedbackon theReputation Registry. Both directions matter:
key doesn't escape it; the signal travels with the agent's identity to every
other service that reads the registry.
interaction with Celo, which makes it unusually well placed to bootstrap an
agent's reputation. That's a real reason to register, and it closes the loop
with phase 2: a first-time agent pays the toll, an agent with a clean record
stops paying.
Constraints this has to respect, and I'd want agreement on these specifically:
registry is a real harm to a third party. Trigger on objective, logged events
— sustained burst-limit violations, repeated requests for addresses that
immediately sweep. Not on a hunch, and not on every
429.payout. Queue it, the way payouts already go through the RTDB queue.
revokeFeedbackexists; build the revoke path and adocumented appeal route at the same time as the write path, not later.
feedback. Key management, funding and monitoring are real work, and are the
main reason this is phase 3 rather than phase 1.
Questions
different identity primitive?
source we want to be, and are mechanical triggers plus a revoke path enough
of a safeguard?
Related: #272 (the original API-key design, still open), #284 / #285 (GEO pass,
independent of this).
All reactions