@recensus/sdk
v4.0.1
Published
Know Your Agent on Robinhood Chain: label your agent's transactions and x402 payments so they are counted and checkable on Recensus. viem wrappers, request signing and server middleware.
Maintainers
Readme
@recensus/sdk
KNOW YOUR AGENT. Recensus is KYA for Robinhood Chain: anyone can check
what an agent has done on-chain, who stands behind it and what it has at
stake (recensus.xyz/check). This package puts
your agent's activity on that record. It labels your agent's transactions on
Robinhood Chain (4663) — 24 bytes appended to calldata, or the EIP-3009 nonce of an x402
payment (RECENSUS-1) — so they are counted
under your agent, checks that the label is safe to add, and verifies
labelled agents on your own server. To check agents before you serve them,
see @recensus/kya.
npm i @recensus/sdk viemLabel every transaction
import { createRecensus, deriveAgentId, robinhoodChain } from '@recensus/sdk';
import { createPublicClient, http, parseEther } from 'viem';
const recensus = createRecensus({
agentId: deriveAgentId(operator, 'price-watcher'),
flags: { autonomous: true }, // no human approves each send
// flags: { test: true } // in staging: excluded from public numbers
});
const chain = robinhoodChain(); // 4663, ETH for gas; robinhoodChain(rpcUrl) for your own node
const publicClient = createPublicClient({ chain, transport: http() });
const wallet = recensus.wrap(walletClient, publicClient);
await wallet.sendTransaction({ to: recipient, value: parseEther('0.01') }); // native ETH
await wallet.writeContract({ address, abi, functionName: 'transfer', args: [to, amount] });Default-safe on Robinhood Chain: native ETH sends to an EOA, ERC-20
transfer, approve and transferFrom (USDG included), and Permit2. Each is
proven labelled == unlabelled on a fork by the conformance suite.
wrap changes nothing else about the client. writeContract is passed to
your wallet's own writeContract with the label as dataSuffix, so every
field you set is kept.
When the label is not added
The label must never break a transaction. Before each send, the SDK:
- Checks the call. No label on a contract deployment, on calldata sent
to an address with no code, on a contract marked
tagSafe: falsein the registry, on a wrapper such ashandleOps, on anything in yourdenylistordenySelectors, or on calldata that already carries a label. - Simulates both versions. The labelled and unlabelled calls run side by side. The label stays only if both succeed with the same return data. If the labelled call reverts, returns something different, or turns a revert into a success, the transaction goes unlabelled. It also goes unlabelled if the node cannot be reached.
- Makes room for it. If you set
gas, it is raised by the label's measured cost, at leastLABEL_GAS_ALLOWANCE(1,000). If the label does not fit, the call is sent unlabelled at your limit.
Every unlabelled send is reported through onUnlabelled, with a warning by
default. A scoreboard row is a small thing to lose; a transaction is not.
A simulation cannot see a contract that reads its own calldata without
reverting or returning anything different, such as one that hashes
msg.data or an ERC-2771 forwarder target. If you call one, put it in
denylist.
Label x402 payments
An x402 exact payment is sent by the seller or its facilitator, not by you, so
there is no calldata of yours to label. The label goes in the EIP-3009
authorization's 32-byte nonce instead (RECENSUS-1 §3.4), and the token logs
it in AuthorizationUsed with you as the authorizer.
import { x402Client } from '@x402/core/client';
import { x402ExactScheme, x402Networks } from '@recensus/sdk';
const client = new x402Client();
for (const network of x402Networks()) { // eip155:4663, eip155:46630 (testnet)
client.register(network, x402ExactScheme(recensus, account)); // a viem account
}Payments are in USDG. For a stablecoin in the chain config the EIP-712 domain
(Global Dollar / 1) comes from the config, not from the seller, and a
seller naming another domain is refused before signing.
Or build the payload yourself:
const { x402Version, payload } = await createX402ExactPayment(recensus, account, paymentRequirements);It signs locally, valid from a minute ago for min(maxTimeoutSeconds, 600)
seconds, and refuses anything but scheme exact over EIP-3009 on an
eip155:<chainId> network (X402RefusedError, before signing). A Permit2
payment is refused rather than paid unlabelled. recensus.nonce() gives a
fresh labelled nonce for any other EIP-3009 authorization;
parseNonceLabel(nonce) reads one back.
Verify labelled agents on your server
Hono:
import { requireRecensus } from '@recensus/sdk/middleware';
app.use('/api/*', requireRecensus({
apiUrl: 'https://recensus.xyz/_api',
origin: 'https://shop.example', // this server's own origin: required
minTier: 'claimed',
}));Express: capture the raw body first, so the signature is checked over the bytes that were sent.
import express from 'express';
import { requireRecensusExpress, captureRawBody } from '@recensus/sdk/middleware';
app.use(express.json({ verify: captureRawBody }));
app.use('/api', requireRecensusExpress({
apiUrl: 'https://recensus.xyz/_api',
origin: 'https://shop.example',
minTier: 'claimed',
}));The identity is on c.get('recensus') in Hono and req.recensus in Express.
Without captureRawBody, Express refuses any request that carries a body
with BAD_SIGNATURE: a re-serialised req.body is not what the agent
signed. Both are also exported from the package root.
The agent signs each request with the key it sends from. The middleware
checks, in order: the clock skew, the signature, a /v1/verify lookup, that
the key is a declared sender of that agent, that the agent is not suspended,
that its tier is high enough, and last the nonce. The nonce is recorded only
after every other check passes, so a failing request takes no slot, and a
replay still fails. The identity's agentId and key are lowercase.
The signed URL is built from origin, never from the request's Host, so a
request signed for another server does not verify on yours.
minTier must be claimed or staked:<digits>. Anything else throws when
the lane is built, rather than letting every claimed agent in. Stakes are
compared as BigInt.
The default nonce store is in memory for one process, one per lane. Behind
more than one instance, pass a shared nonceStore. It keeps a nonce until
its timestamp leaves the skew window, and caps any one key at 1,000 live
nonces (memoryNonceStore(maxEntries, maxPerKey)), so one agent can't fill
it for the rest.
Each lane has its own lookup cache (30 s) and budgets. Lookups are budgeted
per signing key (lookupsPerKeyPerMinute, default 10), so a key that invents
agent IDs spends only its own budget. The lane's ceiling (lookupsPerMinute,
default 60) serves cached answers instead of refreshes once it's spent, and
never refuses an agent whose key is within budget. Concurrent lookups for one
agent share one request, and a failed lookup is remembered for 5 s. Pass
budgetKey(input) to budget by the socket address as well
(input.request is the Hono context or the Express request). Put your own
per-agent rate limit in front of the lane too.
Rejections are 403 with a code: MISSING_HEADERS, STALE_TIMESTAMP,
REPLAY, BAD_SIGNATURE, KEY_NOT_BOUND, TIER_TOO_LOW, SUSPENDED.
VERIFY_UNAVAILABLE means Recensus could not be reached, gave an answer of
the wrong shape, or this key has spent its lookup budget with nothing cached,
so you can decide whether to fail open. LANE_BUSY means this key holds its
share of live nonces, or the store is full of them; it never forgets one to
make room.
Agents sign with recensus.signRequest({ method, url, body }, account).
4.0
- Robinhood Chain (4663) replaces Arc:
robinhoodChain(rpcUrl?)androbinhoodTestnetChain(rpcUrl?)replacearcChain, and x402 payments are in USDG oneip155:4663. Arc support is removed; the last Arc versions are 3.0.0–3.1.0. See CHANGELOG.md.
2.0
requireRecensusrequiresorigin. 1.x rebuilt the signed URL from theHostheader, so one merchant could replay a request at another.wrapneeds something to simulate with: a public client, or a wallet client viem can extend. 1.x sent everything unlabelled when it had nothing to simulate with. 2.0 refuses to wrap instead.- A caller-set
gasis raised for the label rather than left too low. - The simulation compares return data, not only reverts.
Docs: https://recensus.xyz/docs/get-counted · Agent lane: https://recensus.xyz/docs/agent-lane · License: MIT
