anon-rpc Specification

View on GitHub →

This document is the normative specification for anon-rpc, a standard that lets a wallet or application make anonymized RPC requests by running hash-pinned client code inside a sandboxed worker, and granting that code a small, explicit, transport-neutral capability API.

Appendix A gives non-normative design rationale.

1. Introduction and scope

A wallet that wants to read from or write to a chain must reach an RPC endpoint. Doing so through a fixed gateway concentrates observation: the gateway learns who asks for what. anon-rpc lets the wallet instead run a pluggable anon-client that routes requests through an anonymity network, while keeping that client code from touching wallet secrets, cookies, or the DOM.

This specification covers the full system:

The KPS wire protocol itself is out of scope and is defined by the KPS project (see §15). This document specifies only the worker-facing KPS API and the harness obligations behind it.

2. Terminology

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals.

3. Conformance classes

3.1 Conforming harness

A conforming harness MUST:

3.2 Conforming worker

A conforming worker MUST:

The worker SHOULD minimize its usage of ambient APIs other than the capability API (§7). Usage of other APIs will prevent the worker from functioning on platforms which do not provide them.

A conforming worker MUST NOT depend on the messaging protocol used to implement the capability API.

3.3 Secondary roles

A Specifier Contract MUST conform to §4.

4. Worker identity and integrity

The contents of §4–§5 are derived from the anon-rpc proposal article (§15).

A worker bundle MUST be identified by content hash, not by location. A specifier contract is an on-chain object exposing at least:

interface IWorkerSpecifier {
  // keccak256 hash of the canonical worker bundle bytes.
  function workerHash() external view returns (bytes32);
  // Suggested locations from which the bundle MAY be retrieved.
  function workerResolvers() external view returns (string[] memory);
}

A harness MAY obtain the bundle bytes by any means. Any bytes matching the hash are equally acceptable regardless of source; workerResolvers() is advisory only.

If the bundle hash does not equal workerHash(), the harness MUST reject it.

4.1 Resolver entries

Each workerResolvers() entry is one of:

A harness MUST ignore entries it does not recognize.

A blob: entry denotes bytes already held by one running program, so it cannot appear usefully in a deployed specifier contract. It is for a host that constructs a specifier locally around bytes it already has, such as a developer running a worker that has not been published. It changes nothing else: the pinned hash is still the identity, and the bytes are still verified against it before they run.

The kps resolver grammar:

kps-resolver = "kps:" kps-addr path
kps-addr     = <a KPS Address, verbatim (§10)>
path         = "/" path-absolute        ; per RFC 3986

Example:

kps:198.51.100.7:12298:uEiAxk...9Qw/keccak/19/4f04bde4925f6bbb0bd8bdfceca7251125eaa0664ce3c0c25dce2a1545338d

4.2 Fetching a bundle over KPS

To fetch from a kps resolver, a harness dials the address (browser harnesses over WebRTC, native over QUIC, per §10) and performs one request/response exchange on one KPS stream, in HTTP/1.1 syntax. The syntax is deliberately plain HTTP so that ordinary HTTP software behind a byte-stream bridge can serve bundles; the profile below is strict, and a recipient observing a violation MUST abandon the exchange rather than recover leniently.

Request — the harness writes, then calls closeWrite():

GET <path> HTTP/1.1
Host: <certhash of the dialed address>

Response — the harness reads a status line, header fields, an empty line, then body bytes until EOF:

The stream carries exactly one exchange: after the response body, both sides close. Connection reuse happens at the KPS layer — one connection, many streams.

Integrity never depends on the transport: whether bytes came from https: or kps:, and whatever content coding carried them, only keccak256(bytes) == workerHash() over the decoded bytes admits them (§4).

5. Host-side harness API

The harness code MUST implement AnonRpcWorker for the host:

export class AnonRpcWorker {
  constructor(init: WorkerInit);

  // Resolves when the worker is loaded and has called its signalReady() method.
  // Rejects when the worker cannot be started or has failed (see below).
  ready: Promise<void>;

  // Implements the web's fetch API. This field MUST be this-bound; it MUST work
  // normally when used as a free function. This means a function accepting
  // fetch as a parameter can accept it as `useFetch(worker.fetch)` and not
  // require `useFetch(worker.fetch.bind(worker))`.
  fetch: typeof fetch;

  // Resolves with the next log entry the worker has produced (§13), waiting
  // for one if none is retained. The host pulls; the harness never pushes.
  // An abort withdraws the caller without consuming an entry.
  acceptLog(opts?: { signal?: AbortSignal }): Promise<LogEntry>;

  // Clean up resources. For a web harness this means the iframe and its web worker
  // are removed.
  close(): void;
}

export type LogEntry = {
  level: "debug" | "info" | "warn" | "error";
  args: LogArg[];
};

export type WorkerInit = {
  // The contract specifier address
  address: string;

  // Delivered to the worker as `anonRpcWorker.config` (§7). Opaque to the
  // harness; its meaning is defined by the worker.
  config?: unknown;

  // Where a browser harness loads its null-origin document from, for an
  // embedder whose Content Security Policy will not let the harness construct
  // one inline (§6). A harness that does not use an iframe MUST ignore it.
  iframeUrl?: string;

  preExisting?: {
    // An ethereum rpc provider used to break the circular dependency: we need
    // to read the chain in order to instantiate our anonymous system for reading
    // the chain.
    rpcProvider?: RpcProvider;
  };
};

ready MUST reject when the worker cannot be started or has failed:

After such a failure the harness MUST also fail all pending and future fetch calls; nothing may hang awaiting a worker that will never serve.

6. Worker isolation

A conforming harness MUST run the worker such that it has no ambient access to:

A browser harness MUST run the worker in a Web Worker whose owning context is a null-origin (sandboxed, allow-scripts only) iframe, and MUST mediate all capability traffic across the postMessage boundary.

How that iframe is constructed is the harness's choice. Building it inline — srcdoc carrying a bootstrap script — asks nothing of the host and is the usual way. It is not always available: a srcdoc document is loaded from a local scheme and therefore inherits the embedder's Content Security Policy, and a policy can only be tightened from within a document, never relaxed. An embedder whose policy omits 'unsafe-inline' — an MV3 browser extension, or any site with a strict script-src — cannot run such a bootstrap at all.

WorkerInit.iframeUrl (§5) serves those embedders. When it is present, a browser harness MUST load the null-origin document from that URL instead of constructing one, and:

iframeUrl grants the worker nothing. It says where the empty room comes from, not what is in it. A harness MUST NOT treat a document loaded this way as more trusted than one it constructed, and MUST deliver the bundle, the config and the capability port identically in both cases.

7. The capability API

The harness MUST implement AnonRpcWorkerApi for the worker:

export type AnonRpcWorkerApi = {
  signalReady(): void;
  signalFailed(reason?: { code?: string; message?: string }): void;
  acceptCall(opts?: { signal?: AbortSignal }): Promise<IncomingCall>;
  config: unknown;
  kps: KpsApi;
  storage: StorageApi;
  log: LogApi;
};

export const anonRpcWorker: AnonRpcWorkerApi;

The worker MUST call signalReady() when it is ready to fulfil fetch calls.

The worker MAY accept calls to fetch before it calls signalReady(). The harness MUST buffer incoming calls so that this is not necessary.

The worker MAY call signalFailed(reason?) — before signalReady(), to report that it cannot become ready (bad config, unreachable network, unsupported platform, …); or after it, to report an unrecoverable failure. The harness MUST then fail the worker: ready (if pending) MUST reject, and pending and future calls MUST fail, with an error carrying reason's code and message (§12 discipline: host logic may branch on code, never on message). Failure is final: signalReady() after signalFailed(), and repeated signalFailed() calls, MUST be ignored.

7.1 Config

config carries the host's WorkerInit.config (§5) to the worker:

8. Inbound calls

export type IncomingCall =
  | FetchCall;
// future call kinds are added here, discriminated by `kind`

export type FetchCall = {
  kind: "fetch";
  url: string;
  requestInit?: AnonRequestInit;
  respond(response: AnonFetchResponse | Promise<AnonFetchResponse>): void;
};

9. Fetch call payloads

9.1 Requests

export type HeaderList = [name: string, value: string][];

export type ByteBody = Uint8Array | ReadableStream<Uint8Array>;

export type AnonRequestInit = {
  method?: string;                      // defaults to "GET"
  headers?: HeaderList;
  body?: ByteBody;
  redirect?: "follow" | "manual" | "error";  // defaults to "follow"
  signal?: AbortSignal;
};

9.2 Responses

export type AnonFetchResponse = {
  status: number;
  headers: HeaderList;
  body: ByteBody;
  url?: string;         // final URL after followed redirects
};

10. KPS transport

KPS provides secure, multiplexed byte streams to a peer identified by a certificate hash. The worker-facing API is connection-first, and tracks the KPS specification, version ^0.2.1 (§15); a harness MUST implement KPS behaviour compatible with that version.

export type KpsAddr = string;  // "<ip>:<port>:<certhash>", e.g. "192.0.2.1:4242:uEi..."
                               // IPv6 hosts are bracketed: "[<ipv6>]:<port>:<certhash>"

export type KpsApi = {
  dial(addr: KpsAddr, opts?: KpsDialOptions): Promise<KpsConn>;
  openStream(addr: KpsAddr, opts?: KpsDialOptions): Promise<KpsStream>;
};

export type KpsDialOptions = { signal?: AbortSignal };
export type KpsOpenStreamOptions = { signal?: AbortSignal };

10.1 Connections

export type KpsConn = {
  remoteAddress: { ip: string; port: number };
  openStream(opts?: KpsOpenStreamOptions): Promise<KpsStream>;
  acceptStream(opts?: { signal?: AbortSignal }): Promise<KpsStream>;
  sendDatagram(data: Uint8Array, opts?: { signal?: AbortSignal }): Promise<void>;
  receiveDatagram(opts?: { signal?: AbortSignal }): Promise<Uint8Array>;
  close(reason?: KpsReason): Promise<void>;
  closed: Promise<KpsConnCloseInfo>;
};

10.2 Streams

A KPS stream is an unnamed, reliable, ordered, bidirectional byte stream. Write boundaries are not preserved: if one side writes hello then world, the peer observes the bytes helloworld in arbitrary chunking. Application protocols, routing, framing, and request/response semantics are the worker's responsibility, layered on top of the stream bytes.

export type KpsStream = {
  readable: ReadableStream<Uint8Array>;
  writable: WritableStream<Uint8Array>;
  closeWrite(): Promise<void>;
  cancelRead(reason?: KpsReason): Promise<void>;
  resetWrite(reason?: KpsReason): Promise<void>;
  close(reason?: KpsReason): Promise<void>;
  closed: Promise<KpsStreamCloseInfo>;
};

The lifecycle is QUIC-inspired but is not a QUIC API mapping:

A harness MUST NOT expose stream IDs, connection IDs, QUIC transport parameters, 0-RTT, connection migration, version negotiation, or detailed flow-control knobs.

10.3 Datagrams

Datagrams are connection-level, unreliable, unordered, message-oriented, and size-limited, and are kept entirely separate from streams. There is no notion of an "unreliable stream". They are sent and received directly on the connection (§10.1):

11. Storage

export type StorageKey = string;

export type StorageApi = {
  get(key: StorageKey, opts?: StorageReadOptions): Promise<Uint8Array | undefined>;
  set(key: StorageKey, value: Uint8Array, opts?: StorageWriteOptions): Promise<void>;
  delete(key: StorageKey, opts?: StorageWriteOptions): Promise<void>;
  has(key: StorageKey, opts?: StorageReadOptions): Promise<boolean>;
  list(opts?: StorageListOptions): AsyncIterable<StorageKey>;
  clear(opts?: StorageClearOptions): Promise<void>;
};

Storage is asynchronous and binary-first.

12. Error model

export type KpsErrorCode =
  | "cancelled" | "closed" | "reset" | "timeout" | "network-error"
  | "protocol-error" | "unsupported" | "too-large" | "queue-full"
  | "permission-denied" | "internal-error";

export type KpsReason = { code?: KpsErrorCode; message?: string };
export type KpsConnCloseInfo = { ok: boolean; reason?: KpsReason };
export type KpsStreamCloseInfo = { ok: boolean; reason?: KpsReason };

13. Logging

export type LogApi = {
  debug(...args: LogArg[]): void;
  info(...args: LogArg[]): void;
  warn(...args: LogArg[]): void;
  error(...args: LogArg[]): void;
};

export type LogArg =
  | string | number | boolean | null | undefined
  | Uint8Array
  | LogArg[]
  | { [key: string]: LogArg };

The log API is console-like but does not promise browser console semantics.

13.1 Delivery to the host

A host reads log entries by calling acceptLog() (§5). The host pulls, one entry per call; the harness MUST NOT require a host to read them at all.

Where a harness routes entries no host ever collects is not specified.

14. Security considerations

15. References

Appendix A: Design rationale (non-normative)

An API, not a message protocol

The sandboxed worker communicates with the host via messaging, but this specification standardizes a wrapped API rather than the messages themselves. The API is easier to specify, easier to use, and leaves the wire encoding as an implementation detail of each harness:

// standardizing the messages would look like this…
addEventListener("message", async (ev) => {
  if (ev.data.type === "fetch") {
    const response = await anonymousFetch(ev.data.url, ev.data.requestInit);
    postMessage({ type: "fetch-result", response });
  }
});

// …instead, the worker writes this (§7–§8)
while (true) {
  const call = await anonRpcWorker.acceptCall();
  switch (call.kind) {
    case "fetch":
      call.respond(anonymousFetch(call.url, call.requestInit));
      break;
  }
}

IncomingCall is discriminated by kind so future call kinds can be added without growing the accept surface.

Why KPS is a built-in capability

Granting the worker kps (§10) removes the temptation to give the worker code a full-page iframe so it can reach WebRTC directly — which would unnecessarily lock implementations to the web. KPS server listeners accept both WebRTC and QUIC clients, so a non-web harness simply uses QUIC, and the worker neither knows nor cares which transport carries its streams.