Skip to main content

Attestor API

Not deployed

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​

MethodPathBody / queryResponse
GET/v1/health{ ok: true }
GET/v1/metricsAuthorization: Bearer <METRICS_TOKEN>Prometheus text. Operator-only: 404 when METRICS_TOKEN is unset
GET/v1/infoAttestorInfo
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/startdeprecated: ?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 as beneficiaryHint to deployToken (emitted in TokenLaunched).

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.

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

  1. Without a PAYMENT-SIGNATURE header the attestor answers 402 { error, code: "payment_required" } with PAYMENT-REQUIRED (base64 JSON PaymentRequired). It holds one accepts entry: scheme: "exact", network: "eip155:56", USDT (0x55d398326f99059fF775485246999027B3197955), the price in atomic units, payTo, and extra: { assetTransferMethod: "permit2-exact", name, version, signerAddress, spenderAddress } from the Binance OnchainPay ("B402") facilitator. resource.description is always zk-pad attestor lookup.
  2. The client signs a Permit2 PermitWitnessTransferFrom for exactly that amount (after a one-time ERC-20 approval of the token to Permit2) and retries with PAYMENT-SIGNATURE.
  3. 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: 402 payment_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.