When a web wallet reads the Ethereum chain or sends a transaction, it almost always goes through one of a small set of HTTPS gateways or node-as-a-service (NaaS) providers who in turn depend on cloud and internet infra providers like Cloudflare. These gateways run Ethereum nodes and translate HTTPS requests into messages on the public Ethereum network; the browser, restricted to HTTPS, has no practical alternative. The gateways see who’s asking what, and they can decide to stop answering. The edge is jailed.

The network they’re translating to is Ethereum’s own peer-to-peer protocol, devp2p, which is public and permissionless. Anyone can run a node. So what’s actually concentrated isn’t the network, it’s the role of serving browsers. devp2p nodes are built to talk to each other and reciprocate useful data; one-way consumers get dropped. Gateway operators are special because they’ve volunteered for the asymmetric job.

The thing to unlock, then, isn’t devp2p itself but rather the browser’s ability to talk to anyone willing to serve it. There are ways 1 2 to let browsers open direct, encrypted connections without DNS or a certificate authority. The counterparty no longer has to be a CA-signed HTTPS service; any node operator who wants the job can take it. Accessibility to Ethereum no longer depends on a handful of gateways.

That counterparty still sees who’s asking, though. For that we borrow what’s already built: anonymizing routing networks like Tor and mixnets. The anonymization layer can sit in front of any counterparty, or the counterparty can live inside the anon network itself. Either way, you get privacy. And since different designs make different tradeoffs, any one might slow down, get compromised, or drop traffic, which means swapping them needs to be a simple config change.

The rest of this post sketches what such Standardized Interface ☝️ might look like; a prelude to the Abstract Access Layer architecture that is underway.

But before we get to it, a small note:

The arrows landing on Ethereum P2P in the above figure are doing a lot of heavy lifting: what’s on the wire? Today a wallet speaks JSON-RPC over HTTPS to a gateway. But here we are talking about the edge opening a direct channel 1 2 to a counterparty, so the message shape on that channel needs to be thought through. This ties to ongoing efforts to modernize the Ethereum RPC standard itself toward a leaner format (SSZ) and more granular queries that fit the stateless-first future of Ethereum.

So the rpcUrl: "/ethereum-rpc" in the snippets below indicates a stable logical handle (“I want Ethereum RPC”), not a peer address; per-request peer or circuit selection happens inside the loaded client (Web Worker, discussed in the Isolating the Code section below), abstracted from the wallet.

Standardizing the Interface (for the Web)

Picking a winner among anonymization networks isn’t on the table; there are too many candidates, they make different tradeoffs, and the space is still moving. The interface a wallet uses to plug one in, though, is a much more tractable target.

Every modern browser includes a built-in function called fetch. It sends a request to a URL and returns the response, and almost every JavaScript library that talks to a server is built on top of it:

fetch(url)
# -> Response
function fetch(
  input: RequestInfo | URL,
  init?: RequestInit,
): Promise<Response>;

What we need is an anonymous version of fetch. Such a function could be used for anything, but we’ll narrow our focus to Ethereum RPC.

We want web-based wallets to be able to write something like this:

provider = RpcProvider(
    fetchInit: <some anonymization config>,
    rpcUrl:    "/ethereum-rpc",   # placeholder; wire shape over WebRTC is TBD
)

result = provider.request("eth_sendRawTransaction", ["0x<signed tx>"])
# eg -> "0x88df...944b" (tx hash)
import { RpcProvider } from ;

const provider = new RpcProvider({
  some: 'kind',
  of: 'config',
  // (aka fetchInit)

  rpcUrl: '/ethereum-rpc',  // placeholder; wire shape over WebRTC is TBD
});

await provider.request({
  method: 'eth_sendRawTransaction',
  params: ['0x<signed tx>'],
});
// eg -> "0x88df...944b" (tx hash)
./RpcProvider.ts
import proposedStandardInterface from '<somewhere>';

export class RpcProvider {
  #rpcUrl: string;
  #fetch: typeof fetch;

  constructor({ rpcUrl, ...fetchInit }: FetchInit & { rpcUrl: string }) {
    this.#rpcUrl = rpcUrl;
    this.#fetch = proposedStandardInterface.makeFetch(fetchInit);
  }

  async request(
    method: string,
    params: unknown[] = [],
  ): Promise<unknown> {
    const res = await this.#fetch(this.#rpcUrl, {
      method: 'POST',
      body: JSON.stringify({
        jsonrpc: "2.0",
        method,
        params,
        id: makeADecentRandomNumber(),
      }),
    });
  }
}

It’s possible that RpcProvider itself will be part of the proposed standard, but I think it’s better to leave it as an example instead. This lets us focus on defining the interesting part of the standard:

makeFetch(fetchInit)
# -> fetch
# (what's fetchInit? what does makeFetch do?)
export function makeFetch(
  fetchInit: FetchInit, // What is this type?
): typeof fetch {
  // What do we put here?
}

If we succeed, wallet developers will be able to effortlessly switch between anonymization networks by simply updating their fetchInit.

Wallets could even make this configurable at the user level, or give the users the option to auto-pilot the whole thing under the hood: the pinging of anon networks for speed tests, the automatic switching to the most reliable anon network, the default behavior if none of the anon networks are reachable for some reason (say, a kill switch but users can explicitly force-send by directly plugging into the Ethereum P2P).

Why not just import it?

You might reasonably ask why makeFetch is even needed. Why not just:

import anonymousFetch from 'my-provider';

That way wallet developers can just swap my-provider when they want to switch systems. We could just say “fetch is the standard. The end.”

Problems:

  1. The provider is baked into the build. Switching requires shipping a new release.
  2. Excessive due diligence is needed to switch providers, because if my-provider is malicious or compromised, the attack surface is unnecessarily large:
    • Bad code in the wallet likely means unlimited spying, including stealing funds by capturing and exfiltrating private keys.
    • npm install my-provider runs code as the development user, likely granting similar near-complete exploit potential for those users, including CI servers.
  3. We’re relying on npm. I mean, maybe that’s fine, but I think we can do better 🤓.

FetchInit

How do we get the client code for a given anonymization network into the wallet? We want something secure and flexible, and that continues working if the developer walks away.

Naturally, we believe in solving this problem with Ethereum, so we can simply put an Ethereum address here:

FetchInit:
  address    # smart contract address on Ethereum
export type FetchInit = {
  address: string;
};

Resulting in, for example:

provider = RpcProvider(
  address: "0x0123..cdef",
  rpcUrl:  "/ethereum-rpc",
)
const provider = new RpcProvider({
  address: '0x0123..cdef',
  rpcUrl: '/ethereum-rpc',
});

Unfortunately client code is too big (or at least we need to support code that is too big) for Ethereum to store it directly. Instead the smart contract needs to define two things:

  1. What the code is.
  2. Where to get it.

This can be solved with an interface like this:

IChainFetchProvider:
  workerHash()       # -> hash of the worker bundle
  workerResolvers()  # -> suggested places to fetch the bundle
interface IChainFetchProvider {
    /// keccak256(workerBundleBytes)
    function workerHash() external view returns (bytes32);

    /// suggested ways to retrieve the worker bundle
    function workerResolvers()
        external
        view
        returns (string[] memory resolvers);
}

Chicken and Egg

Reading the smart contract requires Ethereum RPC. So we need RPC to set up RPC. That pre-existing RPC is either public or derives its anonymity some other way.

This is the bootstrapping problem every p2p network faces, somebody has to get you through the door.

A sensible starting point: use globalThis.ethereum, but also enable override:

FetchInit:
  address
  preExisting?:
    rpcProvider?      # how to read the chain to bootstrap

RpcProvider:
  request(method, params)   # -> result
export type FetchInit = {
  address: string;
  preExisting?: {
    rpcProvider?: RpcProvider,
  };
};

type RpcProvider = {
  request(
    method: string,
    params: unknown[],
  ): Promise<unknown>;
};

A preExisting rpcProvider is an optional boot RPC to read the resolver contract at address, falling back to globalThis.ethereum if omitted.

Resolvers

Verifying the hash is simple enough. But what about resolvers?

It may seem simple - just put a url there and fetch it. But what is fetch? Uh oh. Another chicken and egg:

FetchInit:
  address
  preExisting?:
    rpcProvider?  # how to read the chain
    resolve?      # how to fetch the worker bundle  (new!)
export type FetchInit = {
  address: string;
  preExisting?: {
    rpcProvider?: RpcProvider,
    resolve?: typeof fetch, // New!
  };
};

fetch is a convenient fallback, but it’s not a great default. The problem is that it requires HTTPS, and with HTTPS you need two things that we feel are unnecessarily permissioned:

  1. A domain name.
  2. A signature from an authority allowing you to use TLS on that domain name.

This can be solved with WebRTC, but that itself is a standardization challenge. In the meantime, there are at least two candidates:

Writing your own resolve is one solution, but to ensure resolving over WebRTC works as a common standard, we need to define a default. Something like this:

resolve(resolver, opt?)
  if resolver is webrtc-direct or kps   -> wrtc.resolve
  if resolver starts with /ipfs/        -> ipfs.io gateway
  otherwise                             -> browser fetch
export function resolve(
  resolver: string,
  opt?: RequestInit,
) {
  if (wrtc.matches(resolver)) {
    return wrtc.resolve(resolver);
  }

  if (resolver.startsWith('/ipfs/')) {
    return resolve(`https://ipfs.io${resolver}`, opt);
    // (Or some better way to do IPFS)
  }

  // Others?

  return fetch(resolver, opt);
}

Secondary Discovery

The resolver list in the smart contract is a hint, not a requirement. The standard verifies the hash; it doesn’t care where the bytes came from. An implementation is free to try peer-to-peer gossip, community-maintained mirror lists, IPFS pins added years after deployment, or anything else. Any byte stream that hashes correctly is accepted.

The provider contract pins a worker hash and suggests resolvers; bytes can come from any resolver or competing discovery system, and are accepted only if they match the hash.

This matters more than it might seem. Hash-pinning lets anyone host the bundle, and secondary discovery lets new hosts be found without ever touching the original contract. The system can self-sustain indefinitely after its operators walk away.

The same channel can carry more than mirror locations:

  • Alternative resolvers, if the original list dies.
  • Successor systems, a different smart contract address advertised as an informal successor when the original is no longer maintained.
  • Unofficial upgrades, a community-blessed updated bundle.

How a particular system handles secondary discovery (who can advertise what, how trust is signaled, what tools browse the options) is a topic of its own. Different systems can compete on it, we don’t need to standardize it here.

Isolating the Code

Rather than simply running the resolved code, we can run it in a Web Worker sandboxed in an iframe. This dramatically reduces the attack surface: no access to cookies, the DOM, or the host’s JS environment.

The wallet host page contains a sandboxed iframe, which contains a Web Worker running the anon client code. The worker reaches the network directly and talks to the host over postMessage, while the DOM, cookies, wallet.js, and private keys stay walled off from it.

This means the generated fetch function needs to be constructed by proposedStandardInterface, and communicate with the anon client via postMessage:

(These are rough sketches, missing error handling etc)

# in the host
fetch(url, init):
  send to worker: { type: 'fetch', url, init }
  wait for worker reply with type 'fetch-result'
  return reply.response
// in the host (part of proposedStandardInterface)

async function fetch(
  url: RequestInfo | URL,
  init: RequestInit,
): Promise<Response> {
  worker.postMessage({ type: 'fetch', url, requestInit });

  return new Promise<Response>((resolve) => {
    worker.addEventListener('message', ev => {
      if (ev.data.type !== 'fetch-result') {
        return;
      }

      resolve(ev.data.response);
    });
  });
}
# in the worker (the anon client)
on message from host:
  if type is 'fetch':
    response = anonymousFetch(url, init)
    send to host: { type: 'fetch-result', response }
// in the worker (the anon client)

addEventListener('message', ev => {
  if (ev.data.type === 'fetch') {
    const response = await anonymousFetch(
      ev.data.url,
      ev.data.requestInit,
    );
  
    postMessage({
      type: 'fetch-result',
      response,
    });
  }
});

Messaging should probably provide the anon client with a few other things:

  • Logging
  • Storage (otherwise the anon client would be entirely stateless, having to cold-start every time)
  • WebRTC-direct/kps (Web Workers don’t have RTCPeerConnection)

Each one is its own rabbit hole, but we’ll stay on higher ground.

WebAssembly

The anon client can embed and run WebAssembly in this model. It’s expected that WebAssembly will be useful to these clients, but it is not necessary to specify that they will use it.

WebAssembly itself could be used as an isolation layer, and has the advantage of working outside the browser. However, WebAssembly is usually distributed with JS bindgen code anyway. If we rely on WebAssembly as an isolation layer, we would need to standardize the bindgen, and that would bring in a lot of complexity.

Non-Browser JS

This solution assumes <iframe/>, so it won’t run out of the box in NodeJS/Bun/Deno.

Deno does allow sandboxing Web Workers, so it should be simple to write a variation of proposedStandardInterface which can load the same addresses and corresponding anon clients.

This means you could also use Deno as a subprocess and make it work that way, so any environment that can spawn Deno and talk to it could use this method, including NodeJS and Bun, but also native binaries and other platforms.

There are other options too, but this is a high-level post so we don’t want to get carried away.

Beyond Anonymous RPC

The interface above is shaped around anonymization, but the underlying transport (ie browsers opening direct, encrypted connections via WebRTC Direct or KPS) gets used in three different places in this post:

  1. Reaching a blob host to download the anonymization client code, given a hash and a list of resolvers from a smart contract.
  2. Reaching anon network nodes, once the client is running inside the worker. The worker doesn’t have RTCPeerConnection of its own, so the host hands it a WebRTC/KPS transport via postMessage.
  3. Reaching an Ethereum-serving counterparty directly, without an anonymization layer at all. For users who want censorship-resistance without anonymity, this is enough.

The third case is worth a moment. “Direct devp2p” sounds appealing (just connect to the network itself), but devp2p nodes serve each other. A browser asking for state and giving nothing back gets dropped quickly.

Therefore the unlock isn’t really “skip the middleman”, it’s “anyone can be the middleman.”

The wallet still needs a counterparty; the gain is that the counterparty no longer has to be a Cloudflare-grade HTTPS operation. Someone running a node who’s signed up to serve browsers is enough.

The interface has the same shape: an address points to client code that knows how to find and talk to the appropriate counterparties. Only what the client does, and what’s on the other end, changes.

Are we describing a “New Web”?

We’re targeting anonymous/decentralized RPC for Ethereum, yes, but there is a deeper idea here about defining how things are connected. So far we’ve assumed data will be resolved by a web context, which is presumably a webpage served over https.

But we could avoid this too by shipping the core loader in a browser extension. Or shipping the browser itself with a core loader. <iframe/> accepts blob urls - you can then allow the iframe to consume most or all of the page, so it becomes the page itself as far as the user is concerned.

Springboard is a prototype of this.

Accessibility for Networks, Freedom for Users

A standard is only as good as its adoption. For this one, that means two sides have to find it worth showing up for: the networks providing anonymity / access to Ethereum P2P, and the wallets and users plugging into them.

Accessibility: Anonymity network teams 0, 1, 2, 3, 4, 5, including new entrants and incentivized variants, need a way to reach real users. Today, this would mean bespoke integrations with each wallet and frontend, one by one. But with a standard, that becomes a single implementation that works everywhere the spec is supported.

Freedom: Wallets, defi frontends, dApps, and their users need to switch networks safely and without friction, instead of being tied to whichever one was baked into the current release. Once that choice lives in configuration rather than in the build, users can swap, wallets can offer several, and new networks can show up without negotiating their way in.

Networks compete on merit instead of partnerships, and users aren’t locked into a single pick. Users may very well be tapping into multiple networks dynamically: when one provider slows down, gets compromised, or drops traffic, switching is a runtime config change rather than a release cycle.

This is the work we’re focused on. A draft specification and reference implementation should land later this quarter; we welcome feedback and contributions from anyone working on either side.