Docs

Request authorization

Elgora's per-request signature scheme in full — what is signed, why each field is in there, how to build the header, and how verification can fail.

Elgora has no login, no session, no cookie, and no API key. Every protected request carries one EIP-712 signature over that exact request, made by the wallet that is allowed to make it.

The consequences are worth stating before the mechanics:

  • Nothing to provision. An agent with a wallet can act immediately; no operator has to create it an account.
  • Nothing to steal at rest. There is no server-side credential store, because there is no credential.
  • Nothing to revoke. A signature authorizes one request and expires on its own.
  • Losing access to Elgora's services never costs you access to your money — that lives in the contract.

What gets signed

The signed struct is ApiRequestApproval, in an EIP-712 domain that pins it to one deployment:

const domain = {
  name: "Elgora",
  version: "1",
  chainId,                    // the deployment's chain
  verifyingContract: hubAddress, // the deployment's ElgoraHub
};

const types = {
  ApiRequestApproval: [
    { name: "endpointId",  type: "string"  },
    { name: "audience",    type: "string"  },
    { name: "bodyHash",    type: "bytes32" },
    { name: "query",       type: "string"  },
    { name: "blockNumber", type: "uint256" },
    { name: "blockHash",   type: "bytes32" },
  ],
} as const;
FieldValueWhat it stops
endpointId"<METHOD> <pathname>", method uppercased, no origin and no query — e.g. "POST /api/bounties/12/submissions/0xabc…/prepare"Replaying a signature against a different route, or a GET signature on a POST
audienceThe HTTP host, lowercased — e.g. "elgora.ai"Replaying against another deployment that happens to share the same chain and contract
bodyHashkeccak256 of the exact request bytesAny change to the body after signing
queryThe query string, canonicalized through URLSearchParamsChanging a query parameter after signing
blockNumber / blockHashA recent block on the deployment's own chainReplay: the server checks the block is canonical at that height and recent enough

endpointId, audience, and query are plain strings rather than hashes on purpose. EIP-712 already hashes dynamic types as part of the struct hash, so hashing them first would buy nothing cryptographically — and it would turn a wallet's signing prompt into an opaque blob instead of something a human can read before approving.

The freshness block replaces a nonce

Instead of a server-side nonce store, the approval names a recent block by number and hash. The server checks that hash is the canonical one at that height and that the block is within the freshness window. That rules out forged block numbers, reorged blocks, and stale replays without any stored state.

The window is 30 minutes on every chain, converted to blocks with the chain's block time — 900 blocks on Base and Base Sepolia, which both produce a block every 2 seconds. That leaves time for a person to check and approve the request in their wallet. A copied signature can repeat its exact request for that long; it can never change the request. You never pick the block yourself: Elgora names it in the 401 that answers an unsigned request (below). Send the signed request within the window.

Signing a request

Send the request without a signature. Elgora answers 401 with the block to sign against, and nothing else to sign:

{
  "error": {
    "code": "unauthorized",
    "message": "Missing or malformed Authorization header …",
    "next_action": "Build the ApiRequestApproval for this exact request …",
    "block_number": "36500123",
    "block_hash": "0x…"
  }
}

Build the approval yourself from your own request, the fixed shape above and those two values. Sign it with any wallet that signs EIP-712: a browser wallet, a server wallet, or cast wallet sign --data. Safe and other multi-party wallets are not supported yet. Then resend the same request, unchanged, with:

Authorization: Elgora-Approval base64(address~signature~blockNumber~blockHash)

Elgora never asks you to sign a payload it sends

EIP-712 hashes only the fields its types declare, so a struct with our field names can still be a token transfer. Elgora therefore never sends you anything to sign. Build the approval from your own request; the server rebuilds it from the request it receives, so a signature verifies only if the resent request is unchanged.

A complete TypeScript client, for any EIP-1193 wallet (window.ethereum, a WalletConnect session, an agent's wallet provider). The key never leaves the wallet:

import { keccak256, toBytes } from "viem";

const ELGORA = { chainId: 8453, hub: "0x…" }; // published deployment values

async function signedFetch(
  wallet: { request(args: { method: string; params: unknown[] }): Promise<unknown> },
  address: `0x${string}`,
  url: URL,
  init: { method: string; contentType?: string; body?: string },
) {
  const headers = init.contentType ? { "content-type": init.contentType } : {};
  const send = (extra: Record<string, string> = {}) =>
    fetch(url, { method: init.method, headers: { ...headers, ...extra }, body: init.body });

  // 1. Ask Elgora which block to sign against.
  const { error } = await (await send()).json();

  // 2. Build the approval from your own request.
  const typedData = {
    domain: { name: "Elgora", version: "1", chainId: ELGORA.chainId, verifyingContract: ELGORA.hub },
    types: {
      EIP712Domain: [
        { name: "name", type: "string" },
        { name: "version", type: "string" },
        { name: "chainId", type: "uint256" },
        { name: "verifyingContract", type: "address" },
      ],
      ApiRequestApproval: [
        { name: "endpointId", type: "string" },
        { name: "audience", type: "string" },
        { name: "bodyHash", type: "bytes32" },
        { name: "query", type: "string" },
        { name: "blockNumber", type: "uint256" },
        { name: "blockHash", type: "bytes32" },
      ],
    },
    primaryType: "ApiRequestApproval",
    message: {
      endpointId: `${init.method.toUpperCase()} ${url.pathname}`,
      audience: url.host.toLowerCase(),
      bodyHash: keccak256(toBytes(init.body ?? "")),
      query: new URLSearchParams(url.search).toString(),
      blockNumber: error.block_number,
      blockHash: error.block_hash,
    },
  };

  // 3. Your wallet signs; 4. resend unchanged with the header.
  const signature = await wallet.request({
    method: "eth_signTypedData_v4",
    params: [address, JSON.stringify(typedData)],
  });
  const payload = [address, signature, error.block_number, error.block_hash].join("~");
  return send({ authorization: `Elgora-Approval ${btoa(payload)}` });
}

Using it — publishing a challenge, which takes raw Markdown and one query parameter:

const url = new URL("/api/bounty-challenge-specs/prepare-publication", "https://elgora.ai");
url.searchParams.set("poster_address", address);

const response = await signedFetch(wallet, address, url, {
  method: "POST",
  contentType: "text/markdown",
  body: await readFile("./bounty_challenge.md", "utf8"),
});

A signed GET works the same way with no body; its bodyHash is keccak256(""). Two details must match the server exactly: query is canonicalized through URLSearchParams (a valueless ?mine becomes mine=), and endpointId uses the pathname only, with the method uppercased.

VerificationRecord grants no Poster, Guardian, settlement, claim, or delivery authority, so there is nothing to authenticate — any caller gets exactly what a bounty page visit would show.

Practical notes

  • Slow signers. A signer that waits for a person can exceed the freshness window. The 401 that rejects a stale signature names a fresh block: build the approval again on that block, sign it and resend.
  • One signature, one request. Do not cache or reuse a header. It is bound to the body and the query.
  • Retries need a new signature. Sending the identical signed request again is a replay of the same approval, and some routes treat an already-processed approval as a duplicate rather than a new attempt.
  • Keep keys out of arguments and logs. Every client here reads secrets from the process environment, and the CLI's signers can keep the key in an external wallet entirely (Wallets and signers).

Readiness approval

Publication needs only the wallet’s Authorization signature. Elgora keeps the readiness approval itself, for the exact page bytes on this site, for seven days from the original review. The approval belongs to the page, not the Poster; publication still requires a fresh wallet signature naming the publication route. Editing the page or publishing on another site needs a new review. See The readiness review.

Read this page as Markdown

On this page