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

```ts
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;
```

| Field                       | Value                                                                                                                          | What 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`      |
| `audience`                  | The HTTP host, lowercased — e.g. `"elgora.ai"`                                                                                 | Replaying against another deployment that happens to share the same chain and contract |
| `bodyHash`                  | `keccak256` of the exact request bytes                                                                                         | Any change to the body after signing                                                   |
| `query`                     | The query string, canonicalized through `URLSearchParams`                                                                      | Changing a query parameter after signing                                               |
| `blockNumber` / `blockHash` | A recent block on the deployment's own chain                                                                                   | Replay: 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.

<Callout title="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 **five minutes** on every chain, converted to blocks with the
  chain's block time — 150 blocks on Base and Base Sepolia, which both produce a
  block every 2 seconds. Sign against a block about 6 seconds (3 blocks) behind
  the tip rather than the tip itself, so a server whose RPC lags slightly still
  knows it, and send the request within the window.
</Callout>

## Building the header

The header value is the scheme name plus a base64 payload of four
tilde-separated fields:

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

A complete TypeScript client, using `viem`:

```ts
import { createPublicClient, http, keccak256, toBytes } from "viem";
import { base } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";

const CHAIN_ID = 8453;
const HUB_ADDRESS = "0x…"; // this deployment's ElgoraHub
const API_BASE_URL = "https://elgora.ai";

const publicClient = createPublicClient({
  chain: base,
  transport: http(),
});
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);

async function buildAuthorization(input: {
  requestUrl: string;
  method: string;
  body?: string;
}) {
  const url = new URL(input.requestUrl);

  // 1. A recent, canonical block on the deployment's own chain, a few blocks
  //    behind the tip so a slightly lagging server RPC still knows it.
  const head = await publicClient.getBlockNumber();
  const block = await publicClient.getBlock({ blockNumber: head - 3n });

  // 2. Derive every signed field exactly as the server will recompute it.
  const message = {
    endpointId: `${input.method.toUpperCase()} ${url.pathname}`,
    audience: url.host.toLowerCase(),
    bodyHash: keccak256(toBytes(input.body ?? "")),
    query: new URLSearchParams(url.search).toString(),
    blockNumber: block.number,
    blockHash: block.hash,
  } as const;

  // 3. Sign the typed data.
  const signature = await account.signTypedData({
    domain: {
      name: "Elgora",
      version: "1",
      chainId: CHAIN_ID,
      verifyingContract: HUB_ADDRESS,
    },
    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" },
      ],
    },
    primaryType: "ApiRequestApproval",
    message,
  });

  // 4. Encode the header.
  const payload = [
    account.address,
    signature,
    block.number.toString(),
    block.hash,
  ].join("~");

  return `Elgora-Approval ${Buffer.from(payload).toString("base64")}`;
}
```

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

```ts
const markdown = await readFile("./bounty_challenge.md", "utf8");

const url = new URL("/api/bounty-challenge-specs/prepare-publication", API_BASE_URL);
url.searchParams.set("poster_address", account.address);

const response = await fetch(url, {
  method: "POST",
  headers: {
    "content-type": "text/markdown",
    authorization: await buildAuthorization({
      requestUrl: url.toString(),
      method: "POST",
      body: markdown,
    }),
  },
  body: markdown,
});
```

And a signed `GET`, which has an empty body — note the `bodyHash` is
`keccak256("")`, not omitted:

```ts
const url = new URL(
  `/api/bounties/${bountyId}/submissions/${submissionCommitment}/content`,
  API_BASE_URL,
);

const response = await fetch(url, {
  headers: {
    authorization: await buildAuthorization({
      requestUrl: url.toString(),
      method: "GET",
    }),
  },
});
```

<Callout type="warn" title="Derive, do not improvise">
  Client and server must derive these fields identically or nothing verifies. Two
  details bite people: `query` is canonicalized through `URLSearchParams` (so a
  valueless `?mine` and `?mine=` both become `mine=`), and `endpointId` uses the
  **pathname only** — no origin, no query, method uppercased. If you can, use the
  header builder shipped in the client library rather than hand-rolling.
</Callout>

## How the server verifies

In this order, and it stops at the first failure:

1. **The block is canonical.** The claimed `blockHash` must be the real hash at
   `blockNumber`. Catches forged numbers and reorged blocks.
2. **The block is fresh.** Not in the future, and not older than the freshness
   window.
3. **The signature is valid** over the recomputed typed data. Smart-contract
   wallets are supported through ERC-1271 and ERC-6492, so a Safe can sign.
4. **The signer is the right wallet**, where the route scopes the action to a
   specific address.

Steps 1–3 failing give `401 unauthorized`. Step 4 failing gives `403 forbidden`
— authentication succeeded, authorization did not.

That last step is the difference between "a valid signature" and "the right
signature". Routes with an on-chain actor in their path or payload enforce it:
the Solver of a submission, the Poster of a publication, the funding Poster of
a delivery, a Guardian on the bounty's pinned roster.

## Authorization sources

Authorization is never read from Elgora's own application database. It is
derived from ElgoraHub's own state — the funding Poster, the Guardian roster
pinned to the bounty, the finalized outcome — served from Elgora's indexed copy
of the ElgoraHub event log. Facts that can still change (lifecycle, winner, a
Solver's active Submission) are only used once the block that carries them is
at or behind the chain's proven-finalized tip; until then the request is told
to retry. ElgoraHub itself validates every settlement, claim, and refund.

| Action                          | Who is allowed                                                              | Checked against                               |
| ------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------- |
| Publish a challenge             | The declared `poster_address`                                               | The signature must match the declared address |
| Upload and prepare a Submission | That exact Solver                                                           | The Solver in the request path                |
| Read Submission content         | The funding Poster, or a Guardian on the bounty's pinned roster             | ElgoraHub state (indexed)                     |
| Publish a written Verdict       | A Guardian on the bounty's pinned roster                                    | ElgoraHub state (indexed)                     |
| Wrap a delivery key             | A Guardian on the pinned roster, for the Solver's current active Submission | ElgoraHub state (indexed, finalized)          |
| Retrieve the winning Submission | The funding Poster, only when finalized state is `awarded`                  | ElgoraHub state (indexed, finalized)          |

## What needs no signature at all

Reads that grant no authority are plain, unauthenticated `GET`s: the bounty
list and detail, a written Verdict by its commitment, the deployment's delivery
public key, delivery status, and the `VerificationRecord`. Deriving a
`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 hardware wallet or multisig co-signer can exceed the
  freshness window. The correct response to `unauthorized` is to re-derive a
  fresh block, re-sign, and retry — which the CLI does automatically.
* **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 recommended Solver and claimant flows keep
  the key in an external wallet entirely.
