> For the complete documentation index, see [llms.txt](https://docs.glacislabs.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.glacislabs.com/zero-delta/widget/keyless-proxy.md).

# The Keyless Proxy

The same-origin proxy you deploy so the browser never holds a ZeroDelta key — contract, reference handler and CSP.

The widget never talks to the ZeroDelta gateway directly. It calls a **same-origin endpoint you deploy**, which injects your API key server-side and forwards a fixed set of requests. The browser holds no credential at all.

```
browser  ──►  https://your-app.com/api/widget/v1/*   ──►  https://zd-api.prod.glacis-infra.network/api/v1/*
           (same origin, no key)                      (your key, server-side)
```

This is a hard prerequisite. Until it is deployed, every widget request answers `503 proxy_misconfigured`.

### Getting an API key

ZeroDelta is permissioned — there is no self-serve signup. Integrators are onboarded and KYB'd by the Glacis team, who issue:

* a **viewer key**, which this proxy holds server-side and sends upstream as `x-api-key`, and
* the **base URL** for your environment.

**Ask for both here —** [**Glacis integrations team**](https://t.me/+CXFgMhEqkE85N2Nh)**.** It is the same base URL used for a direct API integration. A key with special rates works with the widget from **v4.7.0**: the gateway returns a marker in the quote's `partnerId`, and the widget carries it into the order. On an earlier version use a standard viewer key — those versions submit `bytes32(0)`, and a quote at a special rate submitted without its marker may remain unfilled. See API Reference.

> **The key is a server-side credential.** It goes in your deployment's environment, never in the browser bundle and never in your repository. If you ever need the browser to hold one, read [If you cannot be same-origin](#if-you-cannot-be-same-origin) first — and prefer not to.

### The contract

Mount a handler at `/api/widget/v1/:path*`. It must implement exactly this.

#### Allowed requests

Everything else is rejected **before any upstream call**. This is never an open relay that attaches your key to an arbitrary path.

| Resource        | Method | Purpose                                                               |
| --------------- | ------ | --------------------------------------------------------------------- |
| `chains`        | `GET`  | the enabled chain registry                                            |
| `tokens`        | `GET`  | per-chain token addresses and decimals                                |
| `routes`        | `GET`  | the live route matrix                                                 |
| `route-health`  | `GET`  | measured lane health, so the widget stops offering broken lanes       |
| `prices`        | `GET`  | the gateway's USD price snapshot, for the dollar line under an amount |
| `quote`         | `POST` | pricing, and the gateway's pre-quote compliance screen                |
| `screen/wallet` | `POST` | the standalone wallet screen — **required for direct settlement**     |

> **`prices` only paints a dollar line.** The card reads it to print "$ 100.00" under an amount; the figure never reaches a quote, a plan or a signature. A deployment that omits the entry answers `404`, and the card simply prints no dollar line — nothing else changes.

> **`screen/wallet` is not optional, and forgetting it fails closed loudly.** A direct settlement never calls `/quote`, so it never reaches the gateway's pre-quote screen; this endpoint is its only compliance control. A deployment that omits the entry answers `404`, the engine reads that as "could not screen", and **direct settlement disappears from the picker**. That is the intended failure direction — but it looks like a missing feature, so check this first if direct routes vanish.
>
> Note it is a **POST because it writes upstream**: it bills a screening provider and appends an audit row. The engine caches and de-duplicates verdicts for that reason — do not call it per keystroke, and count it against your daily cap alongside `/quote`.

> **Forward the query string, and return the body untouched.** The widget reads `tokens` and `routes` a page at a time: it sends `limit` on every request and `offset` from the second page on, and reads `meta.totalCount` and `paging.next` from the response. Pass every query parameter through except your router's own path parameter, as the reference handler below does. A proxy that drops the query string answers every page with the first one, and the widget cannot see every route and token the gateway publishes.
>
> Forward the parameters, never the path. Rebuild the query from the parsed parameters (`URLSearchParams` encodes them), and take the upstream path only from the table above. Appending the incoming URL, or any part of its path, to the upstream request can let a caller reach resources this contract refuses.

#### Environment

| Variable                   | Required | Default                                    | Notes                                                                                               |
| -------------------------- | -------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| `STABLEGENIE_API_KEY`      | **yes**  | —                                          | The viewer key from onboarding, sent upstream as `x-api-key`. Alias: `ZERODELTA_API_KEY`.           |
| `ZD_API_UPSTREAM`          | no       | `https://zd-api.prod.glacis-infra.network` | https only (http allowed on loopback). A trailing `/api/v1` is ignored. Alias: `ZERODELTA_API_URL`. |
| `WIDGET_ALLOWED_ORIGINS`   | no       | —                                          | Comma-separated origins allowed to call the proxy cross-origin. Same-origin is always allowed.      |
| `RATE_LIMIT_PER_MIN`       | no       | `60`                                       | Per-IP request budget.                                                                              |
| `GLOBAL_QUOTE_CAP_PER_DAY` | no       | `5000`                                     | Daily ceiling on the resources that cost money upstream: `quote` **and** `screen/wallet`.           |

> **Never prefix these with a client-side bundler prefix.** `VITE_`, `NEXT_PUBLIC_`, `REACT_APP_` and their equivalents inline the value into the public bundle — which is precisely the leak this proxy exists to prevent.

#### Responses

Every rejection fails closed and carries a machine-readable `code`:

| Status | `code`                | Meaning                                                             |
| ------ | --------------------- | ------------------------------------------------------------------- |
| `403`  | `origin_not_allowed`  | Cross-origin request from an origin not in `WIDGET_ALLOWED_ORIGINS` |
| `404`  | `not_found`           | Path is not one of the seven allowed resources                      |
| `405`  | `method_not_allowed`  | Wrong method for that resource                                      |
| `429`  | `rate_limited`        | Per-IP or daily budget exhausted; carries `Retry-After`             |
| `503`  | `proxy_misconfigured` | No API key configured                                               |
| `503`  | `service_unavailable` | No valid upstream configured                                        |
| `504`  | `PROXY_TIMEOUT`       | The gateway did not respond in time                                 |
| `502`  | `PROXY_ERROR`         | Upstream request failed                                             |

On success, the gateway's status and JSON body pass through unchanged — with one optional addition to a quote, described under [Naming the visitor's country](#naming-the-visitors-country).

> **Never answer with `access-control-allow-origin: *`.** Echo the specific allowed origin and set `Vary: Origin`. CORS is not authentication — the surface is read-only pricing plus a screening call, and every payment is wallet-signed, so the blast radius of abuse is cost and quota, never funds — but these controls bound it, and they only work if each one fails closed.

### Two rules that keep the compliance signal honest

Both are about **what you forward**, and both were tightened in v4.0.0. Get them wrong and the gateway screens the wrong party.

> **Forward the end user's IP, but only from an edge-verified header.** The gateway resolves geo and OFAC jurisdiction from it. If you forward nothing, the gateway sees *your serverless function's* IP and screens that instead of the payer. Trust `x-vercel-forwarded-for` or `x-real-ip` (your platform writes these from the real TCP peer and overwrites any client copy). A raw `x-forwarded-for` is a client-appendable chain — if it is your only signal, take the **last** entry, never the first. With no trustworthy signal, emit **no header at all**; never a placeholder.

> **Never forward a caller-supplied country header.** `cf-ipcountry` and `cf-connecting-ip` are only meaningful behind Cloudflare. On any other host nothing sets or strips them, so they are entirely attacker-controlled — and because the gateway prefers a country header over the raw-IP fallback, forwarding one lets a payer **choose the jurisdiction their payment is judged in**. Forward only a header your own edge writes. An absent signal must never be mistaken for a benign jurisdiction.

> **The gateway believes your forwarded IP and country only once your key is registered as a forwarder.** Until then it ignores both headers and resolves the location of whatever connected to it — your serverless function. The symptom is every visitor being judged in your function's region (a deployment in a US region reports a US jurisdiction for a visitor in Europe). Ask the [Glacis integrations team](https://t.me/+CXFgMhEqkE85N2Nh) to register the key your proxy uses.

### Naming the visitor's country

Optional, and only a display matter. The gateway answers a quote with a policy **jurisdiction** — `compliance.sourceJurisdiction`, for example `EU` — which is a region, not a place. From **v4.9.0** the widget's compliance panel prints the country beside it (`EU · Spain (ES)`) when the decision carries `sourceCountry`, and the region alone when it does not.

The gateway does not return `sourceCountry`; your proxy adds it, from the same edge-verified header it already forwards. The reference handler below does this on a successful `quote` and nowhere else. Three things keep it honest:

* take the country **only** from a header your edge writes — never from the browser;
* never overwrite a `sourceCountry` the gateway returned itself;
* with no trusted country, return the gateway's body untouched.

### Reference handler

A complete implementation for the Vercel Node runtime. No dependencies; only the request and response objects are platform-shaped.

```ts
// api/widget.ts  —  route /api/widget/v1/:path* here
import type { VercelRequest, VercelResponse } from "@vercel/node";

const ALLOWED: Record<string, string[]> = {
  chains: ["GET"],
  tokens: ["GET"],
  routes: ["GET"],
  "route-health": ["GET"],
  prices: ["GET"],
  quote: ["POST"],
  "screen/wallet": ["POST"],
};

const DEFAULT_UPSTREAM = "https://zd-api.prod.glacis-infra.network";
const UPSTREAM_TIMEOUT_MS = 13_000;

/** https only — or http on loopback for a local gateway. A bad value fails closed as "no upstream"
 *  rather than sending the key over the wire. */
function parseUpstream(value?: string): string {
  try {
    const u = new URL((value || DEFAULT_UPSTREAM).trim());
    const loopback = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(u.hostname);
    if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) return "";
    // The proxy appends /api/v1 itself; do not double it.
    return u.origin + u.pathname.replace(/\/+$/, "").replace(/\/api\/v1$/i, "");
  } catch {
    return "";
  }
}

const env = {
  apiKey: (process.env.STABLEGENIE_API_KEY || process.env.ZERODELTA_API_KEY || "").trim(),
  upstream: parseUpstream(process.env.ZD_API_UPSTREAM || process.env.ZERODELTA_API_URL),
  allowedOrigins: (process.env.WIDGET_ALLOWED_ORIGINS || "").split(",").map((s) => s.trim()).filter(Boolean),
  perMin: Number(process.env.RATE_LIMIT_PER_MIN) || 60,
  dailyCap: Number(process.env.GLOBAL_QUOTE_CAP_PER_DAY) || 5000,
};

// Best-effort, per warm instance. Use a shared store (Redis, Upstash) if you need a hard limit.
const ipWindow = new Map<string, { count: number; resetAt: number }>();
let billedDay = { day: "", count: 0 };

function rateLimit(ip: string, billsUpstream: boolean): number {
  const now = Date.now();
  const cur = ipWindow.get(ip);
  if (!cur || now >= cur.resetAt) {
    ipWindow.set(ip, { count: 1, resetAt: now + 60_000 });
  } else if (++cur.count > env.perMin) {
    return Math.ceil((cur.resetAt - now) / 1000);
  }
  if (billsUpstream) {
    const day = new Date(now).toISOString().slice(0, 10);
    if (billedDay.day !== day) billedDay = { day, count: 0 };
    if (++billedDay.count > env.dailyCap) return 3600;
  }
  if (ipWindow.size > 10_000) for (const [k, v] of ipWindow) if (now >= v.resetAt) ipWindow.delete(k);
  return 0;
}

/** Edge-VERIFIED end-user IP only. `x-vercel-forwarded-for` and `x-real-ip` are written by the
 *  platform from the real TCP peer; a raw `x-forwarded-for` is client-appendable, so take its LAST
 *  hop (the one the edge appended), never the first. Returns undefined rather than a placeholder. */
function clientIp(h: VercelRequest["headers"]): string | undefined {
  const first = (v: unknown) => {
    const s = Array.isArray(v) ? v[0] : typeof v === "string" ? v : undefined;
    return s ? s.split(",")[0]?.trim() || undefined : undefined;
  };
  const last = (v: unknown) => {
    const s = Array.isArray(v) ? v[v.length - 1] : typeof v === "string" ? v : undefined;
    if (!s) return undefined;
    const parts = s.split(",");
    return parts[parts.length - 1]?.trim() || undefined;
  };
  return first(h["x-vercel-forwarded-for"]) ?? first(h["x-real-ip"]) ?? last(h["x-forwarded-for"]);
}

function fail(res: VercelResponse, status: number, code: string, message: string) {
  res.status(status).setHeader("cache-control", "no-store");
  res.json({ error: { code, message } });
}

export default async function handler(req: VercelRequest, res: VercelResponse) {
  const method = (req.method || "GET").toUpperCase();
  const host = String(req.headers.host || "");
  const origin = String(req.headers.origin || "");

  // ── origin anchor: same-origin always; cross-origin must be listed; never `*` ──
  let sameOrigin = false;
  try {
    sameOrigin = !!origin && new URL(origin).host === host;
  } catch {}
  if (origin && !sameOrigin) {
    if (!env.allowedOrigins.includes(origin)) {
      return fail(res, 403, "origin_not_allowed", "This origin is not allowed to use the widget proxy.");
    }
    res.setHeader("access-control-allow-origin", origin);
    res.setHeader("vary", "Origin");
    res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
    res.setHeader("access-control-allow-headers", "content-type");
    res.setHeader("access-control-max-age", "600");
  }
  if (method === "OPTIONS") return res.status(204).end();

  // ── path + method allowlist ──
  //
  // Match a canonical parse, then forward a string from OUR OWN table: `base` is passed on only
  // after comparing exactly equal to a key of ALLOWED, so the upstream URL interpolates one of our
  // literals and never the caller's string. Dot-segments, percent-escapes and backslashes are
  // refused before the lookup, which keeps `..` and `%2F` impossible by construction rather than by
  // a property of the table's current contents. (`screen/wallet` contains a slash, so a blanket
  // "reject any slash" test cannot be used here.)
  const segs = (req.query as Record<string, string | string[]>).path;
  const base = (Array.isArray(segs) ? segs.join("/") : String(segs || "")).split("?")[0].replace(/\/+$/, "");
  const canonical = !/(^|\/)\.\.?(\/|$)/.test(base) && !base.includes("%") && !base.includes("\\");
  if (!base || !canonical || !Object.hasOwn(ALLOWED, base)) {
    return fail(res, 404, "not_found", "Unknown widget resource.");
  }
  if (!ALLOWED[base].includes(method)) {
    return fail(res, 405, "method_not_allowed", `${method} is not allowed for /${base}.`);
  }

  // ── fail-closed configuration gates ──
  if (!env.apiKey) return fail(res, 503, "proxy_misconfigured", "Widget proxy is not configured for this environment.");
  if (!env.upstream) return fail(res, 503, "service_unavailable", "Widget proxy upstream is not configured.");

  const ip = clientIp(req.headers);
  const retryAfter = rateLimit(ip || req.socket?.remoteAddress || "unknown", base === "quote" || base === "screen/wallet");
  if (retryAfter) {
    res.setHeader("retry-after", String(retryAfter));
    return fail(res, 429, "rate_limited", "Too many requests — please slow down.");
  }

  // ── forward ──
  // Headers are built by ENUMERATION. Nothing from the browser (cookies, authorization, a client
  // key) is forwarded; the server key is the only credential that ever reaches the gateway. The
  // end-user IP and country go through ONLY when edge-verified — see the two rules above.
  const headers: Record<string, string> = {
    accept: "application/json",
    "content-type": "application/json",
    "x-api-key": env.apiKey,
  };
  if (ip) headers["x-vercel-forwarded-for"] = ip;
  const country = String(req.headers["x-vercel-ip-country"] || "");   // never cf-ipcountry
  if (country) headers["x-vercel-ip-country"] = country;

  const params = new URLSearchParams();
  for (const [k, v] of Object.entries(req.query as Record<string, string | string[]>)) {
    if (k === "path" || v === undefined) continue;
    for (const x of Array.isArray(v) ? v : [v]) params.append(k, String(x));
  }
  const qs = params.toString();
  const url = `${env.upstream}/api/v1/${base}${qs ? `?${qs}` : ""}`;

  try {
    const upstream = await fetch(url, {
      method,
      headers,
      body: method === "GET" ? undefined : JSON.stringify(req.body ?? {}),
      signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
      redirect: "manual",
    });
    let text = await upstream.text();
    // Optional — see "Naming the visitor's country". The one thing ever added to a body: the
    // edge-verified country on a successful quote's compliance decision, never over one the
    // gateway returned itself. Anything unexpected leaves the gateway's bytes untouched.
    if (base === "quote" && upstream.status === 200 && /^[A-Za-z]{2}$/.test(country)) {
      try {
        const payload = JSON.parse(text);
        const decision = payload?.data?.compliance;
        if (decision && typeof decision === "object" && !Array.isArray(decision) && !decision.sourceCountry) {
          decision.sourceCountry = country.toUpperCase();
          text = JSON.stringify(payload);
        }
      } catch {}
    }
    res.status(upstream.status);
    res.setHeader("content-type", "application/json; charset=utf-8");
    res.setHeader("cache-control", "no-store");
    return res.send(text);
  } catch (e) {
    const err = e as { name?: string; message?: string };
    if (err?.name === "TimeoutError" || err?.name === "AbortError") {
      return fail(res, 504, "PROXY_TIMEOUT", "ZeroDelta did not respond in time");
    }
    return fail(res, 502, "PROXY_ERROR", err?.message || "Upstream request failed");
  }
}
```

On Vercel, route it with a rewrite:

```json
{
  "rewrites": [{ "source": "/api/widget/v1/:path*", "destination": "/api/widget" }],
  "functions": { "api/widget.ts": { "memory": 128, "maxDuration": 15 } }
}
```

On Next.js App Router, place the same logic in `app/api/widget/v1/[...path]/route.ts` and export `GET` and `POST`.

> **On a host that is not Vercel**, keep the shape but swap the header names for whichever ones *your* edge writes and overwrites. The rule is the header's provenance, not its spelling.

#### Verify it

```sh
curl -s https://your-app.com/api/widget/v1/chains | head -c 200
```

A JSON chain list means you are done. `{"error":{"code":"proxy_misconfigured"}}` means the key is not reaching the function.

> **The escrow pin is scoped to production.** The widget verifies the escrow address offline against a bundled production map, so pointing `ZD_API_UPSTREAM` at a non-production gateway makes that check fail closed on all nine chains at once. That is by design, not a bug.

### Letting a caller price with their own key

Optional, and **not** part of the reference handler above, which forwards no browser credential. It is what the proxy in the widget's own repository (`api/_proxy.ts`, the one behind the public playground) does, for one purpose: a partner pasting their key into a page you host, to see the prices that key gets.

In that proxy a caller may send its own ZeroDelta key as `x-api-key`, and the request is priced with it **in place of** your server key:

| Behaviour                      | Detail                                                                                                                       |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| On by default                  | Set `WIDGET_ALLOW_CLIENT_KEY=false` to switch it off; the header is then ignored and your key is used.                       |
| Same-origin only               | A cross-origin caller, even an allow-listed one, never has a key relayed, and the CORS preflight does not list the header.   |
| A malformed key is refused     | `400 invalid_api_key`. It is never dropped in favour of your key: that would return your prices under a "their key" label.   |
| It replaces, never accompanies | The two keys are never sent together, and the caller's key is not stored, logged or echoed.                                  |
| The response says whose key    | A response priced with the caller's key carries `x-widget-key-source: caller`. Treat a key as accepted only when you see it. |
| The same bounds apply          | The allowed-requests table and the rate limit are unchanged for a caller with its own key.                                   |

On the page, pass the key to the widget as `apiKey` (see below) and keep it in memory only — never in the URL, in storage or in a snippet.

> **A relayed key is judged at your function's region.** The gateway trusts forwarded IP and country only from a registered forwarder key, and a visitor's own key is not one. Quotes priced with it still price correctly; their compliance jurisdiction is the region your proxy runs in.

### If you cannot be same-origin

Passing `apiKey` in the browser is supported but constrained. The key is attached **only** when `apiBase` is a same-origin relative path, or an absolute URL whose origin matches `trustedApiOrigin`:

```tsx
<StableGenieExchange
  apiBase="https://pay.example.com/api/widget/v1"
  trustedApiOrigin="https://pay.example.com"
  apiKey={viewerKey}
/>
```

> **There is no default trusted origin, on purpose.** The correct value is whichever proxy *you* deploy, so only your integration can know it. Set it to an origin you control — it is the sole destination permitted to receive the key. Protocol-relative and backslash-prefixed values (`//evil.example`, `/\evil.example`) are rejected: they pass a naive "starts with a slash" test but resolve to a foreign origin.

Prefer the same-origin proxy. It is the only configuration where the browser holds no credential at all.

### Content Security Policy

The widget confirms settlement by reading the transaction receipt over a public RPC. If your CSP blocks that call, **a payment that actually succeeded reports as unconfirmed and `onSuccess` never fires.**

Add the nine source-chain RPCs to `connect-src`, alongside your proxy:

```
connect-src 'self'
  https://ethereum-rpc.publicnode.com
  https://optimism-rpc.publicnode.com
  https://arb1.arbitrum.io
  https://mainnet.base.org
  https://mainnet.unichain.org
  https://rpc-gel.inkonchain.com
  https://rpc.plasma.to
  https://rpc.soniclabs.com
  https://rpc.plume.org;
```

Override any of them with `rpcOverrides` if you run your own nodes — then list yours instead.

Token artwork is loaded from the registry's image URLs, so `img-src` needs `https:` (or your own allow-list). Blocked images degrade to a letters glyph; nothing else changes.

The rest of a working policy, for reference:

```
default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none';
form-action 'self'; font-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self';
img-src 'self' data: https:;
```

`style-src` needs `'unsafe-inline'` because the widget injects its stylesheet and writes its theme tokens as inline custom properties.

### What the widget verifies for you

The proxy is one half of the trust model. The other half runs in the browser, and none of it depends on the API being honest:

* **The escrow address is pinned offline.** `0x5e25c8ABc19b88d6A7Ab0D805C77A34987a68b40` on every supported chain, cross-checked against the quote and the chain registry. Because both of those come from the same API, the offline pin is what makes the check meaningful — the widget fails closed on a mismatch.
* **Token decimals are pinned offline** for every supported asset. An inflated decimals value from a compromised registry would move the amount scaling and mint bad calldata; it fails closed instead.
* **Every quote is re-validated against your selection** — owner, receiver, both tokens, both chains, both amounts, and a bounded deadline. Minimum-received is enforced on the encoded calldata, not trusted from the response.
* **Every planned transaction is re-decoded before signing**, with its `value` bound to the plan. Approvals are **exact-amount, never unbounded**.
* **Settlement is confirmed by receipt** over a bundled RPC — never the registry's own — against a nonce baseline captured before broadcast.
* **The compliance decision is disclosed, not just enforced.** Every route carries the checks the gateway actually ran, and the widget asserts only what the server reported — see Configure.

The user's wallet always owns the final signature. The widget never holds keys and never submits silently.

#### Settlement verdicts

| Verdict       | Meaning                                                                       |
| ------------- | ----------------------------------------------------------------------------- |
| `confirmed`   | Mined with a success receipt. The only verdict that fires `onSuccess`.        |
| `reverted`    | Mined with a failure receipt — a real on-chain failure.                       |
| `mismatch`    | The mined transaction does not match the plan.                                |
| `unconfirmed` | Could not establish either. **Must not be presented as a completed payment.** |

### No telemetry

The only network calls the widget makes are to your `apiBase`, to the RPC used for verification, and image loads for token artwork. There is no analytics endpoint, no beacon and no third-party script.

Upstream error messages are scrubbed of URLs, host:port pairs, filesystem paths and stack frames and bounded to 160 characters before they reach a payer's screen. The original still reaches `onError` for your own logging.

### Known limits

* The reference rate limiter is **in-memory per warm instance** — best-effort. A shared store is the production upgrade if you need a hard ceiling.
* The proxy forwards the end user's IP and country so the gateway can screen, plus the request's query string and body — no other header, cookie or credential from the browser.

### Still stuck?

If the proxy is deployed and `curl` still does not return a chain list, or your key is not working, the [Glacis integrations team](https://t.me/+CXFgMhEqkE85N2Nh) is the fastest route — the same channel that issued the key.
