Attestor API
No attestor is deployed or registered on the BSC mainnet FeeVault yet (attestorThreshold = 0),
so social-account binds are disabled and none of these endpoints (including the paid handle
lookups) has an official live instance. This page documents services/attestor for when it does.
Each attestor instance is independent: its own signing key, hint key, pepper and OAuth apps. A
bind needs attestorThreshold distinct attestor signatures over one BindOwner message; the
client talks to k attestors, sends each the same deadline and merges their responses with
collectBindSignatures.
Endpoints
| Method | Path | Body / query | Response |
|---|---|---|---|
| GET | /v1/health | { ok: true } | |
| GET | /v1/metrics | Authorization: Bearer <METRICS_TOKEN> | Prometheus text. Operator-only: 404 when METRICS_TOKEN is unset |
| GET | /v1/info | AttestorInfo | |
| POST | /v1/commitment | { platform, userId?, handle? } (handle is paid with x402) | CommitmentResponse |
| POST | /v1/oauth/:platform/start | { stealthOwner, ids?: bytes32[] (1..64), redirect? } | { url, state } |
| GET | /v1/oauth/:platform/start | deprecated: ?stealthOwner&redirect?&format? | 302 to the provider, or { url, state } with format=json |
| GET | /v1/oauth/:platform/callback | ?code&state (provider redirect) | 302 to redirect#attest=<session> (or JSON SessionResponse) |
| POST | /v1/oauth/telegram/verify | { auth, stealthOwner, id? } | SessionResponse |
| POST | /v1/oauth/:platform/start | { stealthOwner, ids?, redirect? } | { url, state } (x, github, discord); for farcaster { nonce, domain, uri, expirationTime } |
| POST | /v1/oauth/farcaster/verify | { message, signature } | SessionResponse |
| POST | /v1/escrows | { session } | { escrows: Escrow[] } |
| POST | /v1/bind | { session, ids?, contest?, deadline? } | { binds: BindSignature[] } |
Platforms: x (OAuth 2.0 PKCE, scopes users.read tweet.read), github (OAuth app),
telegram (Login Widget hash), discord (OAuth 2.0 + PKCE, scope identify), farcaster (Sign
In With Farcaster, FIP-11). Platform ids: X = 1, Telegram = 2, GitHub = 3, Farcaster = 4,
Discord = 5. Handle lookup in /v1/commitment: X, GitHub and Farcaster fnames; Telegram and
Discord take the numeric userId (a Farcaster .eth name needs the fid). See
packages/sdk/API.md for the SIWF flow.
Request bodies are limited to 16 KiB (MAX_BODY_BYTES); every POST and OAuth route
is rate-limited per IP (20 burst, 20 / min by default).
GET /v1/info
{ chainId, feeVault, attestor, hintPublicKey, platforms, bindDeadlineSeconds, handleLookup }.
attestor is the BindOwner signing address (must be an allowed attestor in the FeeVault);
hintPublicKey is the compressed secp256k1 key that hints are encrypted to;
bindDeadlineSeconds is the longest bind-signature lifetime it signs. handleLookup is
{ mode: "paid" | "off" | "free", platforms, price }: the price of a paid handle lookup
({ network, asset, amount, decimals, symbol, payTo, assetTransferMethod }), shown to the creator
before paying.
POST /v1/commitment (creator, at launch)
The attestor resolves handle → userId (the immutable numeric id) if only a handle is given (a
paid lookup, see below), derives attestorSalt = HMAC-SHA256(pepper, platformId ‖ ":" ‖ userId),
draws a fresh 32-byte nonce and returns:
{
"platform": "x", "platformId": 1, "userId": "783214", "userIdHash": "0x…",
"nonce": "0x…",
"attestorSalt": "0x…",
"handleCommitment": "0x…", // keccak256(abi.encode(uint8 platformId, bytes32 userIdHash, bytes32 attestorSalt, bytes32 nonce))
"kind": 2,
"attestor": "0x…",
"attestorPubKey": "0x…"
}
The creator then:
- computes
beneficiaryId = computeId(2, handleCommitment, fallbackRecipient, fallbackDelay); - builds the hint with
hintFromCommitment(commitment, { fallbackRecipient, fallbackDelay }):{ platformId, userId, nonce, attestorSalt, fallbackRecipient?, fallbackDelay? }; - encrypts it with
encryptHint(hint, [hint key of every attestor of the committee])and passes it asbeneficiaryHinttodeployToken(emitted inTokenLaunched).
The salt must be in the hint. Attestors have independent peppers, so without it the other
committee members could not recompute the commitment, would reject the escrow, and a bind
needing two or more signatures could never be collected. The salt is not a secret: the
commitment's hiding comes from the random nonce, which only the creator and the hint's readers
know.
Hints are threshold 1. Every recipient attestor opens a hint alone with its own key (there is
no share-exchange protocol, so encryptHint refuses threshold > 1). Whoever holds one
recipient's hint key reads the platform and user id of every hint addressed to it.
Paid handle lookups (x402)
A numeric userId is free and makes no external call. A handle makes the attestor call a paid
platform API (X charges per user lookup), so it is paid with x402 v2 (X402_HEADERS in the SDK):
- Without a
PAYMENT-SIGNATUREheader the attestor answers 402{ error, code: "payment_required" }withPAYMENT-REQUIRED(base64 JSONPaymentRequired). It holds oneacceptsentry:scheme: "exact",network: "eip155:56", USDT (0x55d398326f99059fF775485246999027B3197955), the price in atomic units,payTo, andextra: { assetTransferMethod: "permit2-exact", name, version, signerAddress, spenderAddress }from the Binance OnchainPay ("B402") facilitator.resource.descriptionis alwayszk-pad attestor lookup. - The client signs a Permit2
PermitWitnessTransferFromfor exactly that amount (after a one-time ERC-20 approval of the token to Permit2) and retries withPAYMENT-SIGNATURE. - The attestor verifies with the facilitator, resolves the handle, and settles only if it
resolved: 200 with the commitment and
PAYMENT-RESPONSE(settlement tx). Handle not found: 404, nothing settled. Settlement failed: 402payment_failed, no commitment. One payment authorization buys one lookup attempt; a reused one gets 402.
The price is the same on X, GitHub and Farcaster (fnames), and neither the 402 nor the facilitator request names the
handle or the platform. Malformed handles, Telegram and Discord handles (the Discord error explains how
to copy the user ID with Developer Mode) and Farcaster .eth names are refused before the 402; a
numeric Discord id or Farcaster fid sent as handle is taken as the id (free, no lookup).
Without x402 configured, handle requests get 400 unsupported (send the numeric userId); a
facilitator outage gives 503 unavailable.
GET /v1/info reports handleLookup: { mode: "paid" | "off" | "free", platforms, price }
(free exists only for local development and is refused on chain 56). SDK:
AttestorClient.commitment(req, { fetch: payingFetch }) or commitmentWithReceipt (also returns
the decoded PAYMENT-RESPONSE); @x402/fetch with B402ExactClientScheme from
@bnb-chain/b402/client is a paying fetch.
POST /v1/public-handle (creator, public social recipient)
Body { platform: "x" | "github", handle }. The attestor resolves the immutable user id and
returns { platform, platformId, userId, handle, issuedAt, attestation, attestor, handleCommitment }.
attestation is an EIP-712 PublicHandle(uint8 platformId,string userId,string handle,uint64 issuedAt)
signature with domain { name: "ZkPadPublicHandle", version: "1", chainId }. The creator builds
the id with publicHandleId and the plaintext hint with encodePublicHint. See
public social recipients.
OAuth and intent binding
POST /v1/oauth/:platform/start (x or github) stores a random state server-side, bound to
{ platform, stealthOwner, ids, redirect, PKCE verifier } with a 10-minute TTL, and returns the
provider url, which carries only state (AttestorClient.oauthStart). The intent never
appears in a URL, so browser history and proxy or CDN access logs cannot link an IP to the stealth
owner or the escrow ids. redirect must be on the attestor's allow-list. For a popup, open a
blank window in the click handler and set its location when the call resolves.
The legacy GET /v1/oauth/:platform/start puts stealthOwner in the query string. It is kept
for old clients only: it refuses id (400, ids are never accepted in a URL), and operators
turn it off with OAUTH_GET_START=false (it then answers 404).
The callback resolves the immutable user id, revokes the access token, and creates a
15-minute session { platform, userId, stealthOwner, ids }. The session token is delivered in
the URL fragment of the redirect. Telegram logins go through /v1/oauth/telegram/verify
instead; each widget payload is single-use.
Because the intended stealthOwner is fixed before the login, a login cannot be replayed to bind
a different key. Users log in separately with each attestor.
POST /v1/escrows and POST /v1/bind
The attestor matches the session's (platformId, userId) against its hint index (hints from
TokenLaunched, decrypted with its key, each verified by recomputing the commitment and the id).
Account state (registered, owner, pending owner, bindNonces) comes from the attestor's own
event-sourced index of FeeVault events (Registered, OwnerRotated, BindProposed,
BindFinalized, BindCancelled), built from range getLogs over the vault. It never makes
per-id accountOf / bindNonces reads at login, which would show the RPC provider exactly which
escrows belong to one person, and when they log in. When the index is stale and the chain cannot
be reached, both routes answer 503 unavailable; retry.
/v1/escrows lists the session's escrows with registered, pendingOwner and, for an
unregistered account, the register payload. Listing never acts on a pending bind.
/v1/bind returns one BindOwner signature per escrow that has no pending bind and is not
already owned by the session's key:
{ "binds": [{
"beneficiaryId": "0x…", "newOwner": "0x…", "bindNonce": "0", "deadline": "…",
"signature": "0x…", "attestor": "0x…", "register": null
}]}
with newOwner = session.stealthOwner and bindNonce = bindNonces(id).
One deadline for the committee. proposeBind checks all k signatures against a single
deadline, so the client picks it once with
chooseBindDeadline(now, infos.map(i => i.bindDeadlineSeconds)) and sends the same deadline to
every attestor. Each attestor accepts it within
[now + 300 s, now + bindDeadlineSeconds + 300 s] by its own clock (400 otherwise). Without
deadline, an attestor signs now + bindDeadlineSeconds rounded down to a 600 s grid, which
independent attestors only agree on when their settings match.
Escrows carry visibility (public or private). /v1/bind accepts a visibility filter and
refuses a request that would sign both kinds, or a stealth owner already used for the other kind:
bind public and private escrows with separate fresh keys.
Contest and veto
A pending bind may be the user's own from an earlier visit (the web app creates a fresh key per
visit), so the attestor never contests on its own. The client shows pendingOwner, asks the
user, and then sends /v1/bind with contest: true and exactly one id in ids (400
otherwise; contesting several at once would publicly group them as one person's). For that id,
if the pending owner differs from the session's key, the attestor revokes its other attestations
and, with AUTO_VETO, sends vetoBind, instead of signing.
The veto watcher follows BindProposed. For an escrow it knows (or any id with
VETO_UNKNOWN_IDS), a proposal whose new owner it did not attest is vetoed when AUTO_VETO is
on, and only counted otherwise. A veto clears the pending bind, moves no funds, and keeps the
fallback sweep closed for at least 2 × bindDelay.