Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

zero-indexer is privacy for Zcash light wallets, a Shielded Labs product. It stands on two pillars:

  • zero-leak indexing: a light wallet should sync and transact without handing an indexer the raw material to deanonymize it.
  • the Nym mixnet: the transport that unlinks a wallet’s traffic from its source IP and region.

Today a Zcash light wallet leaks. Under the ZIP 307 light-client protocol it talks to an indexer over clearnet, so the operator sees the wallet’s source IP and the timing of everything it does. The problem and threat model lays out that leak and its worst instance, the Orchard to Ironwood migration, which Zooko called the worst privacy-loss event in Zcash history.

Near-term and long-term

Two efforts share a name and a direction but not a scope.

The near-term system is urgent and narrowly scoped: stop transactions that touch the Orchard pool, starting with the Orchard to Ironwood migration, from leaking a user’s IP. It is a small, attested system, deliberately an 80% first step, honest about the 20% it does not cover. The shim and the hub are built and run as attested enclaves, and a real Orchard to Ironwood migration has gone through the whole stack on mainnet: held, batched, and published on the flush cadence, with the operator’s indexer never seeing it. The Nym hop is deployed: an attested pair has run it on the public mixnet since 2026-08-14, though the live pair’s provenance does not yet reproduce, so it is not independently verifiable. STEVE and the key consortium remain design (roadmap has the status table).

The long-term vision is the fuller product the name promises: a wallet-facing private indexer serving queries, not just broadcasts, over Nym, terminated inside an attested enclave, with PIR added later as a hardware-independent layer. That arc is deferred until the migration fix ships. Roadmap traces the three versions (V1 Nym, V2 +TEE, V3 +PIR). Most of this book is the near-term system, because that is what is being built now.

The system in its world

Seen from outside, zero-indexer is one thing: an attested front-end that a wallet talks to exactly as it talks to an indexer today, and that publishes the transactions most at risk on someone else’s schedule rather than the sender’s. Who touches it, and what it touches:

flowchart TB
  USER["Wallet user (the IP at stake)"] -->|"runs"| WAL["Wallet software (zingo, Zashi, ywallet)"]
  WAL -->|"queries + broadcasts, same endpoint URL as today"| ZI["ZERO-INDEXER"]
  OPR["Light-wallet operator"] -->|"deploys the front-end; untrusted for Orchard-touching contents"| ZI
  TO["Hub operator / Trusted Organization"] -->|"operates the batcher; announces a detected attack"| ZI
  AUD["Auditor (any independent party)"] -->|"verifies attestation + CT, without trusting an operator"| ZI
  KC["Key consortium (Caution / Nym / SL / ZF)"] -->|"governs the long-lived keys"| ZI
  ZI -->|"publishes transactions, reads the chain tip"| ZEC["Zcash network"]
  ZI -->|"unlinkable internal transport"| NYM["Nym mixnet"]
  ZI -->|"attested execution, hardware root of trust"| NITRO["AWS Nitro"]
  ZI -->|"ordinary CA certificate, publicly logged"| CT["Let's Encrypt + Certificate Transparency"]
  ZI -->|"hosted on"| CAU["Caution's enclave platform"]

  classDef enclave fill:#1b7f4d,color:#fff,stroke:#0d5233;
  classDef client fill:#2563eb,color:#fff,stroke:#1e40af;
  classDef actor fill:#d97706,color:#fff,stroke:#92400e;
  classDef keyinfra fill:#7c3aed,color:#fff,stroke:#5b21b6;
  classDef external fill:#6b7280,color:#fff,stroke:#4b5563;
  class ZI enclave;
  class USER,WAL client;
  class OPR,TO,AUD actor;
  class KC keyinfra;
  class ZEC,NYM,NITRO,CT,CAU external;

The cast

ActorRoleTrusted for
Wallet userThe person whose IP is at stake. Installs nothing, changes no settingn/a
Wallet softwarezingo, Zashi, ywallet and the rest. No reconfiguration, but must choose aligned anchors and expiry heights within a migration epoch (ZIP 318). The one hard requirement asked of wallets, and the problem explains why a latest-anchor wallet re-links itselfn/a
Light-wallet operatorOne of roughly five to ten organizations running a public indexer. Deploys the front-end behind its own URL, in front of its own unmodified indexerUntrusted for Orchard-touching contents. Still sees ordinary query contents
Hub operator / Trusted OrganizationRuns the central batching service (Caution at launch) and performs detection: verifying attestation, monitoring CT, and publicly announcing signs of an attackUntrusted for contents; relied on for liveness and detection
AuditorAny independent party, in practice often a wallet developer auditing once for all its users (trust has the steps)Verifies without trusting the operator
Key consortiumCaution, Nym, Shielded Labs, the Zcash FoundationHold the long-lived keys as an M-of-N quorum, so no single party controls them

The problem and threat model

Zcash’s shielded pools hide the sender, receiver, and amount of a transaction on-chain. They do not hide the network metadata a light wallet exposes when it talks to the server that indexes the chain for it: the wallet’s source IP address and the timing of its requests. For most traffic that is a background privacy cost. For a transaction that crosses a value-pool boundary, and above all for the mandatory Orchard to Ironwood migration, it is enough to link a real-world network identity to an on-chain balance. zero-indexer’s near-term system (the zero-indexer-shim and zero-indexer-hub, with Nym between them) targets exactly one half of that leak: the migration broadcast.

This chapter describes the system in the present tense. The shim and hub are deployed as attested enclaves, a real migration ran the full stack on mainnet on 2026-08-11, and the shim-to-hub hop has run over the Nym mixnet since 2026-08-14. Per-mechanism status, including what is not yet independently verifiable, is the table in the roadmap.

The ZIP 307 light-client leak

A light wallet delegates chain validation to an indexer (lightwalletd or Zaino) over ZIP 307’s CompactTxStreamer gRPC service: it pulls compact blocks, trial-decrypts them locally to find its own notes, and submits its transactions back through the same indexer.

The protocol gets one half right and the other half wrong. Note contents stay private, since trial decryption is client-side and the indexer never learns which notes are yours. Metadata does not: the indexer terminates the connection, so it sees the source IP, the timing of every request, and the addresses and block ranges queried (ECC and ZecSec have written this up, see the glossary).

The near-term system targets the broadcast half. When a wallet submits a transaction it calls SendTransaction on the indexer over clearnet TLS; the operator terminates that TLS and sees the raw transaction bytes, the source IP, and the arrival moment. The other half, which addresses a wallet looks up, is the query leak, still largely out of near-term scope, with one exception now closed: transaction-detail lookups (GetTransaction) are served by the hub’s indexer rather than the operator’s (see the roadmap).

From metadata to balance: the correlation

A source IP and a timestamp are only metadata until they join to something; turnstile-crossing transactions supply the join. A transaction that moves value across a value-pool boundary reveals that movement in cleartext on the public chain (for a deshield, the receiving transparent output too). So an operator, or anyone who later obtains its logs, holds two halves of a linkage:

  1. From connection logs: source IP X submitted a broadcast at time T.
  2. From the public chain: a turnstile-crossing transaction moving amount Y appeared at roughly time T.

Joining the two by timing links IP address to on-chain transaction to balance. Nothing in the shielded cryptography prevents this: the leak is in the transport, not the transaction. It is a retrospective attack as much as a live one, because the public chain is permanent and connection logs can be kept and joined later.

The acute event: the Orchard to Ironwood migration

Ironwood is the new shielded pool; to migrate, a wallet must broadcast a transaction moving value out of the Orchard pool and into Ironwood. Sent the way light wallets send transactions today, every one exposes a source IP and a timestamp joinable to the resulting on-chain migration. Zooko framed this on the 2026-07-30 all-hands as the worst privacy-loss event in Zcash history: users linking their IP address to their Zcash balance, hourly, for the duration of the migration window.

Migrations are the acute case for three compounding reasons:

  • Mandatory: users cannot opt out to protect themselves; the value has to move.
  • Mass: a large population migrates, so the leak is broad rather than isolated.
  • Concentrated: the window is bounded, so many correlatable broadcasts land close together in time.

Those same properties make migrations the ideal thing to protect first: a mandatory, non-urgent mass of transactions can be batched and published together (the architecture).

Every turnstile crossing leaks

The migration is the acute driver, but the underlying leak is general. Any turnstile crossing (a transaction that moves value across a value-pool boundary) is revealed on-chain and, linked to a source IP, deanonymizes the user. Three shapes:

  • Deshield: shielded value moving to a transparent output.
  • Shield: transparent input moving into a shielded pool.
  • Migration: shielded value moving from one shielded pool into a different shielded pool (Orchard to Ironwood).

The shim’s classifier detects every crossing, but what it isolates is drawn by pool, not by crossing shape: near-term it protects every transaction that touches Orchard, and everything else passes straight through. So an Orchard-to-transparent deshield is batched exactly like an Orchard-to-Ironwood migration, while shields and deshields out of any other pool pass through (the shim has the predicate and the argument for drawing the line there). Deshield and shield crossings exist on mainnet today, independent of Ironwood, so the leak is real for current mainnet traffic, not only for the future migration.

The adversaries

Against the migration broadcast as it works today (clearnet), three adversaries can perform the IP-to-balance linkage:

  • The light-wallet operator (indexer). Roughly five to ten operators run the light-wallet backends (lightwalletd or Zaino). Each terminates its clients’ TLS, so today it sees the migration transaction in cleartext, the source IP that submitted it, and the timing, and can log all three and join them to the public chain at leisure. This is the primary adversary the near-term system is built to blind.
  • A passive network observer. Anyone on the path between wallet and operator (an ISP, a transit provider, a hosting network) sees the connection metadata: which IP contacted the operator and when. Even without the transaction contents, the timing plus the public chain is enough to correlate.
  • The retrospective correlator. Because the public chain is permanent and logs persist, any party that later obtains operator logs or network captures can perform the same join after the fact. The window for this attack does not close when the migration does.

The migration-broadcast threat table

This model is deliberately narrow: the migration broadcast path, and explicit about everything that passes through unprotected. Verifiable means the wallet can check the property cryptographically via attestation, not merely trust that it holds. See architecture for the encryption layers and trust for how attestation works.

Property (migration broadcast)Today (clearnet)zero-indexer (shim + hub)
Migration tx contents hidden from the operatorNoYes (encrypted; the TEE shim keeps the operator blind)
Migration broadcast linkable to source IPYes, linkableNo: the hub publishes, so the on-chain tx carries no wallet IP. The Nym hop additionally hides which operator a migration came from, and is built but not deployed
Timing of the broadcast correlatable to an exposed IPYesOnly above a batch size of one. Batching breaks the link at scale and does nothing at all when a flush publishes a single transaction; see below
Migration tx contents hidden from the hubn/aYes (the hub is an attested TEE; Caution stays blind)
Guarantee is verifiable by the walletNoYes (attested shim + hub)
Query content privacy (which addresses you look up)NoNo (content passes through; but requester IPs are blinded, see below)

Two rows need more than a cell.

  • Timing correlatable. Today the broadcast arrives at a moment the operator records, matching the on-chain appearance. Under zero-indexer the hub accumulates migrations from every shim and publishes them together on a block cadence, so the on-chain publish time no longer matches any one wallet’s submission time. This row is conditional, and the condition is not currently met. The protection is the batch, so it is worth exactly what the batch contains: at a batch of one there is nothing to hide among, and the shuffle, the simultaneous publish, Nym and the TEE all do nothing for that transaction’s timing. An earlier draft of this table answered “No” flat, which overclaimed. At the migration volume measured on mainnet the modal batch is expected to be zero or one; honest limits gives the arithmetic and the adoption threshold at which the row becomes true.
  • Query privacy. Largely unchanged. The system does not protect which addresses a wallet looks up (transaction-detail lookups by txid are now served by the hub’s indexer, but address-level queries still pass through). This is the honesty anchor; the sections below cover it.

Naive vs Nym-aware wallets

Most wallets today do not speak Nym; they reach the shim over ordinary TLS. The model must hold for such a wallet, but not for a completely unmodified one, as the next section explains.

  • For a naive TLS wallet, TLS terminates inside the shim’s enclave, so the operator cannot read the migration transaction. But the operator’s own network still observed that “IP X connected at time T.” What protects that wallet is the batching at the hub: by holding the migration and co-publishing it with others after a delay, the hub ensures the operator cannot time-match “IP X active at T” against the on-chain migration. This is precisely why the shim must be a TEE (to blind the operator to the contents) and why the hub must batch (to break the timing link for the majority of wallets that cannot hide their own IP).
  • A future Nym-aware wallet could encrypt the migration end-to-end to the hub key and route it itself, so the shim would only forward, not decrypt. The near-term design is built so that path drops in later; near-term wallets are assumed naive.

The dependence on batching is the model’s soft spot: batch-timing anonymity is only as strong as how many migrations land in a single flush window. The robust, volume-independent win is the IP unlinking from Nym; the batching is an additional layer whose strength varies with migration density. Honest limits treats this in full.

The one wallet-side requirement: aligned anchors and expiry

The system needs no wallet reconfiguration and no new endpoint URL, but it does need one thing from the wallet software: within a migration epoch, wallets must choose identical anchors and expiry heights. This is the ZIP 318 behavior (minus its network-anonymity defenses, which the shim and hub now supply instead). It is a hard requirement, not a nicety.

The reason is an anchor-linkage attack. A migration transaction commits to an anchor, a note-commitment-tree root that was current when the wallet built it. A wallet that uses the latest anchor stamps its transaction with a timestamp: an attacker who sees the shuffled epoch batch revealed on-chain can match each transaction’s anchor to the moment it was current, then match that moment to the time a given IP submitted a migration. The batching’s timing protection evaporates. Aligning anchors and expiries across the epoch removes the per-transaction timestamp, so all migrations in a batch look alike.

So the protection is for ZIP-318-like wallets whose users have opted out of Tor or Nym, not for completely unmodified wallets. Coordinating this requirement with wallet authors, and aligning the hub’s batch granularity to the granularity at which wallets pick anchors and expiries, are open items (see review).

The query path: content passes through

The near-term system does not hide query content. The following reach the operator’s existing backend in the clear, exactly as today:

  • Most queries. Which addresses or block ranges a wallet looks up still goes straight to the operator’s backend. The one exception: transaction-detail lookups (GetTransaction) are answered by the hub’s indexer, not the operator’s. The broader ZIP 307 query-content leak (address-level lookups) is not closed near-term; it is the deferred vision (see the roadmap).
  • Shields, and deshields that do not spend Orchard. Crossings the classifier detects but does not batch, so their broadcast metadata leaks as today. A deshield from Orchard is batched.
  • All other broadcasts. Transparent-to-transparent and pure intra-pool shielded payments are not crossings at all; they pass through instantly.

What the attested edge protects

Two protections hold today, on top of the deployment primitive itself.

1. Migration broadcasts, fully (and every other Orchard-touching transaction, which gets the same treatment). A migration’s content is hidden from both the operator and the hub host, its source IP is unlinked, and its timing is broken: the strong, end-to-end guarantee, row by row in the table above. The one residual, that the operator can tell that one of its clients migrated but not the amount, is the next section.

2. The operator’s indexer is blinded to requester IPs, by default and verifiably. Because the shim proxies, every query that still reaches the operator’s backing lwd arrives from the shim, on the operator’s own host, never from a wallet’s IP, so the operator’s indexer logs no longer bind a source IP to a queried address, the linkage that sits in every lwd’s logs today by default. (Transaction-detail lookups no longer reach the operator at all: GetTransaction is served by the hub’s indexer.) And because the shim is attested, “we do not log the IP” is a checkable property, not a promise. This removes the passive IP-logging surface, where most real-world risk lives: breaches, subpoenas, careless or sold logs.

The honest boundary on protection 2: the wallet’s IP still reaches the operator’s host at the TCP layer (on Nitro the parent proxies all network into the enclave, and attestation covers the shim, not the parent). So a bad-faith operator can still capture IPs at the network layer and timing-correlate them against the shim-sourced query stream to re-link IP to query. It is a verifiable removal of the default leak, not a guarantee against an active operator; closing that gap needs the wallet over Nym (Nym-aware wallets) or query-timing shaping. Honest limits owns the residual discussion.

Beyond the two, the front-end is tamper-proof and verifiable: a wallet or auditor can confirm it is talking to exactly the attested shim code, not an operator-controlled impostor (see trust). And it is the deployment vehicle for the vision: query shaping, all-broadcast privacy, and eventually PIR can be added to the same attested edge and reach the same drop-in wallets (see the roadmap).

The residual: the operator learns that a client migrated

One leak survives by construction, and the model names it rather than hide it. Because the shim is a drop-in in front of the operator’s own backend, a migration is the single request the shim does not forward to the backing lwd, so an operator watching its own traffic can infer that a given source IP submitted a migration, and roughly when. It does not learn which on-chain transaction or what amount: the hub’s batch mixes that client’s migration with others’ from operators it never sees. So the residual is “IP X migrated something,” not “IP X migrated amount Y,” which is why the table’s source-IP row reads No for the amount even though the bare fact leaks.

This residual is inherent to the drop-in model, and it is why shim-side batching and shim-to-hub cover traffic are rejected as mitigations. Honest limits develops that argument.

Defense-in-depth: detection, not prevention

This is an emergency defense-in-depth measure. It does not cryptographically prevent every attack; it makes the attacks that matter detectable after the fact, which raises their cost and risk. The detection rests on two observable facts: the shim’s TLS key is bound into its attestation, and every legitimate certificate for the domain appears in Certificate Transparency logs.

A Trusted Organization (the party operating the hub) watches both. It verifies the shim’s setup attestation (the private key lives only in the enclave, and no other certificate for the domain is still valid, ideally a fresh domain with no prior certificates), re-verifies whenever the certificate is renewed for any reason, makes anonymous requests to the public URL to confirm the attested key is the one actually served (at least for untargeted users), and monitors CT for any new certificate. If it sees a different public key in use, or a new key appear in CT, it publicly announces that it has detected signs of an attack.

The honest cost of a detection design: an operator’s own mistakes are indistinguishable from an attack. If an operator loses the TEE’s state and must recreate it, or accidentally lets Let’s Encrypt auto-renew the certificate, that trips the alarm as a false positive. Operators must therefore run carefully (disable certificate auto-renewal, guard the enclave state) and accept that operational slips get announced as possible attacks.

The bar this clears is specific: the design aims to be secure against attacks that fall short of entering a new certificate into CT logs and fall short of fully compromising the TEE, including TEE attacks that rewind or replay enclave state, or that observe the enclave’s memory-access patterns (both are open hardening items, see review).

What this does not defend against

Beyond the query leak (out of scope above) and the residual (above), two active attacks are out of scope, and one side channel constrains deployment:

  • Active wallet-tagging. An attacker who can feed a target wallet a false chain can force it to build migrations against uniquely identifiable anchors; those transactions will not be valid, but they become uniquely findable in the revealed batch. An attacker can also hold a target back on the legitimate chain so it uses identifiably old anchors; the only visible symptom is that the user’s incoming funds confirm much more slowly than usual. These are active wallet-breaking attacks, not passive observation, and this design does not stop them.
  • The transaction-size side channel. An attacker can read a migration’s size (its arity) from the TLS ciphertext length at submission time. If one migration in the revealed batch has a distinctive size, that IP is re-linked to it. So migration sizes must overlap across users; if they do not, the batching does not hide a large or unusual transaction.

A near-term deployment blocker sits alongside these: the existing TLS certificate for zec.rocks is valid through October, so until it expires (or a fresh domain is used, or the key is revoked and wallets check revocation), an attacker holding that certificate could still see targeted users’ migrations. See review.

Architecture

The deployable pieces of the shim and hub system, why they are shaped this way, and two diagrams: the data flow, then the trust plane.

The deployable pieces

Two new pieces of attested software plus a transport put an attested, verifiable, tamper-proof front-end at every operator, on which the protections rest and the whole roadmap builds. Five things run:

  • zero-indexer-shim (ZIS): a lightweight, attested router each operator deploys behind its existing public URL (for example zec.rocks:443). To every wallet it looks exactly like the indexer already there, so wallets need no reconfiguration. It forwards almost all traffic untouched to the operator’s backing indexer, and isolates two things: transactions that touch Orchard (diverted to the hub) and GetTransaction (answered by the hub, so a wallet’s lookup for its own migration never reaches the operator). Everything else passes straight through instantly; the backend still sees those contents, but arriving from the shim, not the wallet’s IP. The shim is stateless, holding nothing about what it diverted, which is exactly why every GetTransaction must go to the hub.
  • zero-indexer-hub (ZIH): a central, attested service, designed to run as two or more instances with failover. It does two jobs. It batches: an Orchard-touching transaction is encrypted to a key the local operator cannot access, routed to a hub, batched with those from every other shim, and co-published on a strict block cadence after a short delay, so an observer holding “IP X connected at time T” cannot time-match it to the transaction when it appears on-chain. And it answers lookups: a GetTransaction is served from the hub’s queue while the migration is unflushed (height 0, mempool), otherwise from the hub’s own indexer.
  • Nym, embedded in both binaries (deployed): each side links nym-sdk and runs its own mixnet client in-process, inside the enclave, so there are no proxy sidecars and no untrusted process on the path. It runs only between shim and hub, never wallet-to-shim. An attested pair has run it on the public mixnet since 2026-08-14; the clearnet dial remains in the code but is off at the hub by default (roadmap has the status table).
  • The operator’s backing indexer: the unmodified lightwalletd or Zaino the operator already runs, on its internal address. To it the shim is a single ordinary gRPC client. It serves block sync, address queries, and pass-through broadcasts in cleartext, exactly as today; a diverted Orchard-touching transaction and a wallet’s GetTransaction never reach it.
  • The hub’s indexer: a CompactTxStreamer (lightwalletd or Zaino), distinct from any operator’s, that the hub connects out to over TLS to read the chain tip, publish each flushed batch, and answer a GetTransaction its queue does not hold. Neither enclave runs a validator of its own. (In a single-operator deployment the two indexer roles can collapse onto one instance, which removes the lookup privacy but not the batching.)

Why this shape: Orchard-touching only, and Option B

Orchard first, and only Orchard, is deliberate. The Orchard to Ironwood migration is the acute, mandatory, mass event, and it is not time-sensitive, exactly when batching helps most (a large simultaneous population to hide among) and costs the least (no urgency to broadcast). But the batched class is drawn wider than “migration”: every transaction that touches Orchard is batched, whatever its value balance or destination. That is Zooko’s rule, and the closed-pool argument behind it is in the shim.

Widening the class widens what is delayed, and the honest accounting is that it costs little. An Orchard deshield to transparent is now held for a flush window like a migration, and deshields are ordinarily time-sensitive commerce. But Orchard is closed to new value, so ordinary commerce lives in Ironwood and passes through untouched; what is left in Orchard is legacy balance, and moving legacy balance is not an urgent errand.

The topology is the all-hands call’s “Option B”: a drop-in shim in front of each operator plus central batching hubs, chosen after a more decentralized “Option C” was set aside. (“Option A,” a standalone privacy server users must point their wallets at, is deferred: past experience says getting wallets to change their endpoint URL is nearly impossible.)

Everything else is post-launch: roadmap covers the deferred items, honest limits what this narrow scope does and does not buy.


1. Data flow and trust boundaries

flowchart TB
  subgraph WAL["Wallet (user device)"]
    W["Light wallet (drop-in; not STEVE or Nym aware)"]
  end

  subgraph OP["Operator host  (UNTRUSTED)"]
    subgraph SHIM["zero-indexer-shim enclave  (attested TCB)"]
      STLS["1 TLS terminate (enclave-born key)"]
      SROUTE["2 HTTP/2 path router"]
      SCLASS["3 is_orchard_touching classifier"]
      SPROXY["4 pass-through proxy"]
      SHUB["5 hub-channel client"]
      SNYM["6 nym-sdk client (linked, in-process)"]
    end
    LWD["backing lwd (operator's, unmodified)"]
  end

  OTHER["shims at other operators"]

  subgraph NYMNET["Nym 5-hop mixnet  (untrusted)"]
    NYM["Nym mixnet (Sphinx + cover traffic)"]
  end

  subgraph HH["Hub host  (Caution, UNTRUSTED; 2+ with failover)"]
    subgraph HUBENC["zero-indexer-hub enclave  (attested TCB; no validator inside)"]
      HNYM["nym-sdk listener (linked, in-process)"]
      HDEC["decrypt (STEVE server, designed)"]
      HVAL["re-validate (stateless)"]
      HQ["batch queue (payload-hash dedup, in RAM)"]
      HFLUSH["batch + publish"]
    end
  end

  HUB2["standby hub enclave (shared key)"]

  subgraph NET["Hub's indexer / Zcash network"]
    FN["hub's indexer -> full node(s)"]
    ZNET["Zcash P2P network"]
  end

  NOTE_OP["Residual: the operator learns THAT a client migrated (it is the one request not forwarded to its lwd), not the amount"]
  NOTE_BATCH["Anonymity set = the cross-operator batch; a batch of 1 = no anonymity"]

  %% pass-through path (thin): queries + non-migration txs
  W -->|"TLS (ends in enclave, not STEVE)"| STLS
  STLS -->|"decrypted h2 (in TCB)"| SROUTE
  SROUTE -->|"other queries + streams"| SPROXY
  SROUTE -->|"SendTransaction"| SCLASS
  SROUTE ==>|"GetTransaction (hub-served)"| SHUB
  SCLASS -->|"non-migration"| SPROXY
  SPROXY -->|"queries except GetTransaction + non-migration txs (plaintext to operator)"| LWD
  LWD -->|"relay (clearnet)"| ZNET

  %% migration path (thick): encrypted end to end, bypasses the lwd
  SCLASS ==>|"migration (or fail-safe): encrypt to hub key"| SHUB
  SHUB -.->|"accepted (not yet on-chain)"| W
  SHUB ==>|"SubmitV1 frame (padded to 64 KiB)"| SNYM
  SNYM ==>|"Sphinx (anonymous send + reply SURBs)"| NYM
  OTHER ==> NYM
  NYM ==>|"5-hop (hides shim + region)"| HNYM
  HNYM ==>|"frame (host never sees it)"| HDEC
  HDEC ==> HVAL
  HVAL ==>|"valid, unexpired"| HQ
  HQ ==>|"flush every 20 blocks (~25 min)"| HFLUSH
  HFLUSH ==>|"SendTransaction (batched, shuffled)"| FN
  FN -->|"P2P relay"| ZNET
  HFLUSH -.->|"tip (GetLightdInfo) + lookup fallthrough"| FN
  HDEC -.->|"AckV1 (SURB return; not awaited)"| SHUB
  SHUB -.->|"failover (dedup by payload hash)"| HUB2
  SHUB -.->|"last resort near expiry: direct broadcast over Nym"| NYM
  HUB2 -.-> FN

  NOTE_OP -.- LWD
  NOTE_BATCH -.- HFLUSH

  classDef enclave fill:#1b7f4d,color:#fff,stroke:#0d5233;
  classDef untrusted fill:#c0392b,color:#fff,stroke:#7f261c;
  classDef external fill:#6b7280,color:#fff,stroke:#4b5563;
  classDef client fill:#2563eb,color:#fff,stroke:#1e40af;
  classDef note fill:#fef9c3,color:#000,stroke:#ca8a04,stroke-dasharray:4 3;
  class STLS,SROUTE,SCLASS,SPROXY,SHUB,SNYM,HNYM,HDEC,HVAL,HQ,HFLUSH,HUB2 enclave;
  class LWD untrusted;
  class NYM,FN,ZNET,OTHER external;
  class W client;
  class NOTE_OP,NOTE_BATCH note;
  style OP fill:#fbeae7,stroke:#c0392b;
  style HH fill:#fbeae7,stroke:#c0392b;
  style SHIM fill:#e7f4ee,stroke:#1b7f4d;
  style HUBENC fill:#e7f4ee,stroke:#1b7f4d;
  style NYMNET fill:#eef0f2,stroke:#6b7280;
  style NET fill:#eef0f2,stroke:#6b7280;
  style WAL fill:#e8eefc,stroke:#2563eb;

Reading it: migration is the code’s label for the diverted class (the shim has the predicate). Thin arrows = the pass-through path (queries other than GetTransaction, and non-migration txs), which go to the operator’s unmodified backing indexer as plaintext the operator can read, exactly as today. Thick arrows = the paths that bypass the operator: the migration broadcast, encrypted end to end, and the hub-served GetTransaction. Green = attested enclave processes, the only things that ever see migration cleartext, and note that this now includes each side’s mixnet client, which is linked in-process rather than run as a sidecar; red = the untrusted host and the operator’s own indexer, which never sees the migration path at all; gray = external networks; blue = the drop-in wallet.

Three nested encryption layers are designed for the migration (shim to hub) path, so that only the two attested enclaves ever see cleartext. The deployed hop today has the outer layer only: Sphinx across the mixnet, with the wallet’s own TLS terminated by the platform’s in-enclave proxy before it.

  1. Inner (designed): the tx is encrypted to the hub key at the classifier, so it survives a compromised host.
  2. Middle (designed): STEVE (AES-256-GCM) terminates inside the hub enclave.
  3. Outer (deployed): Nym Sphinx across the 5-hop mixnet.

2. Trust, attestation, and verification plane

flowchart LR
  W["Light wallet"]
  subgraph OPH["Operator host (untrusted)"]
    SENC["shim enclave (attested)"]
  end
  subgraph HUBH["Hub host (untrusted)"]
    HENC["hub enclave (attested)"]
  end
  ATT["AWS Nitro NSM (hardware root of trust)"]
  STAGEX["StageX reproducible build (root hash)"]
  Q["keymaker M-of-N quorum (Caution / Nym / SL / ZF)"]
  AUD["Auditor (independent)"]
  CT["Certificate Transparency logs"]
  NOTE_TRUST["V2 privacy trusts AWS + the hardware, not math; PIR (V3) removes this trust root"]

  STAGEX -->|"software root hash"| ATT
  ATT -->|"binds enclave pubkey + PCRs"| SENC
  ATT -->|"binds enclave pubkey + PCRs"| HENC
  Q -->|"TLS key persistence (cross-boot + upgrade)"| SENC
  Q -->|"single shared hub key (all hubs)"| HENC
  SENC -->|"ACME cert (Let's Encrypt, CT-logged)"| CT
  AUD -->|"fetch /attestation, verify PCRs vs Nitro root"| SENC
  AUD -->|"verify hub attestation + PCRs"| HENC
  AUD -->|"check no shadow cert"| CT
  AUD -->|"reproduce build == attested hash"| STAGEX
  AUD -->|"passes assurance to users"| W
  NOTE_TRUST -.- ATT

  classDef enclave fill:#1b7f4d,color:#fff,stroke:#0d5233;
  classDef keyinfra fill:#7c3aed,color:#fff,stroke:#5b21b6;
  classDef actor fill:#d97706,color:#fff,stroke:#92400e;
  classDef external fill:#6b7280,color:#fff,stroke:#4b5563;
  classDef client fill:#2563eb,color:#fff,stroke:#1e40af;
  classDef note fill:#fef9c3,color:#000,stroke:#ca8a04,stroke-dasharray:4 3;
  class SENC,HENC enclave;
  class Q,ATT,STAGEX keyinfra;
  class AUD actor;
  class CT external;
  class W client;
  class NOTE_TRUST note;
  style OPH fill:#fbeae7,stroke:#c0392b;
  style HUBH fill:#fbeae7,stroke:#c0392b;

The keymaker M-of-N quorum persists keys across cold boots and upgrades and hands the single shared hub key to every hub instance, which is what makes failover clean. The Auditor Role is open to any independent party. The shim’s one-way STEVE handshake performs this same enclave-verification against the hub, automatically and per session.

STEVE mechanics and the honest limits are in trust; the open platform questions in review.

The shim and the hub

The concrete engineering designs for both TEE services, meant to be reviewed together by Anton (Caution) and Zooko. Decision: marks a committed choice. Trust has the STEVE and attestation deep-dive and the honest limits; review collects the cross-party open forks.

The zero-indexer-shim (ZIS)

The ZIS is an attested-TEE proxy an operator deploys behind their existing public URL (e.g. zec.rocks:443). It is a drop-in LWD to every wallet (no reconfiguration or endpoint change; wallets do need aligned anchors and expiry within a migration epoch, see the problem). It forwards most traffic to the operator’s unmodified backing indexer, but intercepts two methods and routes them to the hub: an Orchard-touching SendTransaction (a transaction that carries Orchard actions, the class the code and hub protocol still call a migration), and every GetTransaction, so a wallet’s follow-up on a diverted migration also bypasses the operator. The shim-to-hub hop runs over the Nym mixnet in the deployed pair (roadmap has the status table).

Why a shim, not the whole indexer in a TEE

An earlier plan put the entire indexer (a full Zebra node plus the indexer) inside the enclave, so the operator could see nothing at all. That is expensive: until the enclave platform ships disk support, it runs entirely in RAM at roughly 400 to 500 GB, on the order of $2,000 per operator per month, with about a four-day resync on every restart. That cost wall makes operator adoption unrealistic.

The shim avoids it by being a thin router, not an indexer:

  • Cheap and fast to restart. No chain state inside the TEE, so the RAM and cost wall disappears.
  • Base-agnostic. It fronts whatever the operator already runs, sidestepping lightwalletd versus Zaino.
  • Deployable by the people already running the infrastructure. The roughly five to ten existing operators add the shim.

Status

The shim began as a proof of concept (commit 56394a1a54) that classified SendTransaction with the real vendored zebra-chain parser and only logged the verdict. It is now the shipped component: an Orchard-touching transaction stops at the shim and goes to the hub, GetTransaction is answered by the hub rather than the operator, and the shim holds no per-migration state, so a restart or a second instance loses nothing. The hub is its own crate (zeronym/hub/: queue, batcher, chain connection, its own reproducible build). Both run as attested Nitro enclaves with in-enclave TLS and reproducible StageX builds, and a third party has run the operator runbook (zeronym/shim/deploy/caution/OPERATORS.md) end to end. Per-mechanism status, including which builds currently reproduce, is the table in the roadmap.

What it proves, tested rather than asserted (tested against a hand-rolled h2c mock and a real tonic CompactTxStreamer server rather than a live indexer, which for these properties is stronger evidence: the mock records the exact bytes it received and can stall a stream on command): unknown method paths pass through carrying the backend’s own status, so the proxy is path-agnostic and forward-compatible with methods it has never heard of; a SendTransaction reaches the indexer byte-for-byte with te, grpc-timeout and custom metadata intact and only the origin retargeted; gRPC trailers survive in both response shapes (a real trailers frame, and trailers-only responses where grpc-status rides in the headers); the streaming tests fail by timeout if a body is ever buffered, in both directions; an unreachable indexer answers grpc-status 14 UNAVAILABLE rather than dropping the connection; and the shim redials after the backing indexer restarts.

The shipped shim is transparent to the wallet by design and deliberately not transparent to the operator: a diverted migration and its follow-up GetTransaction never reach their indexer, and that asymmetry is the documented residual (the operator still learns that a client migrated, see honest limits).

Topology and process model

The shim terminates the wallet’s TLS on an enclave-born key, routes by HTTP/2 :path, and sends an Orchard-touching SendTransaction and every GetTransaction to the hub while everything else proxies to the operator’s unmodified indexer. The architecture has the data-flow diagram.

  • Enclave contents (the TCB): the ZIS binary + rustls, plus nym-sdk in the mixnet build, which is linked in-process rather than run as a sidecar and is therefore inside the TCB (trust states what that costs).
  • The backing lwd is untrusted for migrations (it never sees them) and for GetTransaction (also hub-served), and trusted for the rest, which it already serves today. From its perspective the ZIS is a single gRPC client.
  • Supervisor: in-binary. The driver, correlator and client-lifecycle supervisor are spawned as tasks by the ZIS itself, so there is no PID-1 wrapper script and no second process to keep alive.

Request pipeline (the core)

Decision: an HTTP/2 reverse proxy, not a full tonic server. After TLS termination the ZIS routes by the :path pseudo-header:

  • Every path except SendTransaction, GetTransaction, and two control-plane paths (all other queries, all streams, unknown/new methods, other services): proxy verbatim to the backing lwd. The exceptions the shim answers itself are /.well-known/caution/health, served locally, and /attestation, relayed to the platform’s bootproofd. It owns these because on managed Caution under h2c the platform routes them to the app, so a shim that forwarded them would send the attestation health check to the Zcash indexer and fail to boot. The relay is behind ZIS_CAUTION_ATTESTATION (default on) with the dialled address in ZIS_CAUTION_BOOTPROOFD_ADDR; turn it off for BYOC, for non-h2c, or once Caution serves the paths itself, and the shim is a pure proxy again. Forward request headers and streaming body, stream the response body and trailers (grpc-status) back. No decode.
  • /cash.z.wallet.sdk.rpc.CompactTxStreamer/SendTransaction (unary): buffer the one request message (small), strip the 5-byte gRPC length prefix, prost-decode RawTransaction { data }, and classify:
    • pass-through (no value left the Orchard pool) -> proxy to the backing lwd exactly like the fallback, return the backing lwd’s real SendResponse (so the operator’s node actually relays it and the client gets the true result).
    • Orchard-touching (or a fail-safe verdict) -> hand to the hub, and synthesize a gRPC response: SendResponse { errorCode: 0 }, framed with the 5-byte prefix + grpc-status: 0 trailer, so the client sees “accepted.”
  • /cash.z.wallet.sdk.rpc.CompactTxStreamer/GetTransaction (unary): decode the TxFilter, and when a hub is configured, answer from the hub, never the operator. The hub checks its batch queue first (a diverted-but-unflushed migration comes back with height 0, marking it still in the mempool) and otherwise asks the hub’s own indexer. Address-level queries (GetTaddressTxids, GetTaddressBalance, GetAddressUtxos) are not intercepted and still reach the operator, an open gap (see honest limits).

Rationale over a tonic server re-exporting all ~20 methods: this decodes only two message types (RawTransaction for SendTransaction, TxFilter for GetTransaction), still the smallest auditable TEE surface, and hyper handles h2 framing / flow control / trailers for the pass-through. “Proxy to backing lwd” is a shared helper used by both the fallback and the non-Orchard SendTransaction case.

Decision: route on PATH ALONE, and keep the interception set a SUPERSET of every routing predicate any supported backend uses (built). The tonic server Zaino is built from dispatches on req.uri().path() with no HTTP-method guard, so a GET to the SendTransaction path reaches its send_transaction handler. A routing predicate narrower than the backend’s fails open: the backend acts on a request the classifier never saw, which is the false-negative direction, a migration broadcast in the clear. The PoC had exactly that bug (interception gated on method == POST, caught by adversarial review). It now routes through a pure route_for(path) that structurally cannot see the HTTP method, plus a near-miss arm that hands any path whose final segment case-insensitively spells sendtransaction to the classifier with a distinct warning. Being stricter than the thing behind you is not conservative.

Classify before you connect (built). A wallet whose migration is about to be diverted must not cause the operator’s indexer to see a request, or even a connection. The PoC surfaced this and dialled too early (handle() obtained the upstream before it routed), which in a diverting shim would hand the operator a connection-level trace of a wallet that was never going to talk to them. The shipped shim implements the production order: buffer, classify, then connect only if the verdict is pass-through. The upstream pool is lazy and the dial happens after routing, so a diverted migration never opens even a TCP connection to the operator’s indexer (src/proxy.rs states the property at the dial site).

Decision (parse-fail / uncertain classification): fail safe for privacy. If a SendTransaction body cannot be parsed or classified, treat it as a migration (route to the hub) rather than forward it in the clear. A false positive only delays a normal tx (the hub still broadcasts it); a false negative would leak a real migration. The hub validates and broadcasts, so an unparseable/invalid tx is caught there. (Bounded exception if this proves to break a common well-formed shape; its soundness is a review item.)

Compression must be controlled, not relayed. gRPC compression is not supported on the intercept path: a compressed SendTransaction cannot be parsed, so it fails safe to migration. But compression is negotiated: the backing indexer advertises grpc-accept-encoding on responses, so an operator who enables compression pushes every migration into the fail-safe arm instead of genuine classification, an operator-controlled lever on the classifier in a component whose threat model is that the operator is the adversary. The shim therefore rewrites that header to identity on every relayed response, in one place, and leaves the request direction alone, where the header is the wallet’s own statement (built). Response compression (grpc-encoding) is relayed untouched, and the pass-through path stays opaque, so client compression there is fine. A wallet that compresses unprompted still lands in the fail-safe; whether that stays the shipped policy is a review item.

The classifier: detecting an Orchard-touching transaction

Decision (Zooko, second ruling): the predicate is the mere PRESENCE of Orchard actions, and the value balance decides nothing (built).

is_orchard_touching(tx) := tx.orchard_shielded_data().is_some()   # ANY Orchard actions

Zooko’s words: any transaction carrying Orchard actions is potentially security-sensitive, because it could leak something the user did not intend to disclose, and probably time-insensitive, because people and their tooling already expect Orchard to be slow. So the safe default is to divert it whatever orchard_value_balance says.

The gap this closes over the previous rule is the internal shuffle that pays its fee from another pool: Orchard actions present, legacy notes spent and their nullifiers published, and yet a value balance of exactly zero. The earlier predicate handed precisely that to the operator’s indexer in the clear. Note the measured cost of the widening, which is nil: across 144 mainnet blocks at tip 3,433,105, all 111 Orchard-touching transactions already had orchard_value_balance > 0, so the wider rule diverted not one extra transaction. It is prospective cover, not new load.

The balance is still parsed and still logged, as evidence beside the deciding fact (the action count). Orchard only, never Ironwood, and that boundary is load-bearing rather than incidental: the time-insensitivity half of the rationale holds for Orchard, the closing legacy pool, and fails for Ironwood, the new pool where ordinary time-sensitive commerce lives.

The prior ruling, superseded, was the sign of the value balance alone:

is_orchard_exit(tx) := orchard_value_balance(tx) > 0    # value LEAVING the Orchard pool

Its reasoning still explains why Orchard: NU6.3 closes the pool to new value, so anyone still holding Orchard notes has held them since before activation, and spending Orchard at all is the identifying event, revealing that this IP controls legacy funds against a finite and shrinking set. Where the value lands changes nothing. Keep the precision: closed to new value, not to activity, since same-receiver change still lands in the pool.

This replaces a three-conjunct predicate (tx.version == V6 && orchard_value_balance > 0 && ironwood_value_balance < 0). - The Ironwood conjunct handed an Orchard withdrawal to transparent or to Sapling straight to the operator’s indexer in the clear, though it leaks exactly the same fact. Demonstrated, not theorized: the PoC demo used to print passthrough ... orchard_vb=+250000 ironwood_vb=+0 for precisely that shape, and the same fixture now lands in the diverted class.

  • The V6 conjunct passed V5 Orchard spends, which are equally real Orchard activity. Nothing replaces it: orchard_shielded_data() is version-agnostic and absent on a V1-V4 transparent transaction (transaction.rs), which therefore passes by the predicate itself.

All three value-balance sub-cases carry Orchard actions, so all three divert:

orchard_value_balanceShapeVerdict
> 0value leaving Orchard (an exit)divert
== 0same-receiver shuffle, fee from another pool: nullifiers published, nets to zerodivert
< 0value entering Orchard, consensus-invalid post-NU6.3divert (but unmineable; kept only as a directionality probe in tests)

Only a transaction with no Orchard bundle passes through. review keeps the retracted > 0 analysis visible. Each shape has its own test vector (including an Orchard withdrawal with no Ironwood bundle, and a V5 Orchard spend), so a regression toward the narrower predicate fails the suite rather than passing quietly.

A naming note. The diverted class is Class::Migration in the code and the routing helper is treat_as_migration(), but an Orchard deshield or a same-receiver shuffle is not literally a migration. The code’s own accurate name is is_orchard_touching, so this book says Orchard-touching transaction for the class and keeps migration as the legacy label for the same thing.

Decision: parse with zebra-chain (orchard_value_balance() transaction.rs:1503, ironwood_value_balance() :1520, expiry_height() :510), not a hand-rolled parser. A misclassification is a privacy failure, so correctness outweighs the extra dependency weight; a hand-rolled parser would have to walk most of the tx anyway to reach the bundle value balances. Fixture: zaino/live-tests/e2e/tests/ironwood_activation.rs.

The classifier is a pure function fn classify(raw: &[u8]) -> Class (Class = Migration | PassThrough | Unparseable) with unit tests over real vectors. No I/O, no state, no clock, no config, so it is the easy part to audit. Built as classify.rs, which returns the evidence a verdict rests on (version, the Orchard, Ironwood and Sapling value balances, expiry, lengths) alongside the verdict itself, so a log line can never disagree with the routing decision. The Ironwood balance is now evidence only: it gates nothing, and it is logged because it is what shows an operator where an Orchard-touching transaction sent its value, which is how you see the classifier catching the destinations it used to miss.

Full consumption is part of the parse (built). Transaction::zcash_deserialize stops at the end of the transaction and ignores trailing bytes, so a valid tx followed by junk parses Ok. Without a cursor-position check the shim would classify a prefix of what the backing node acts on, Trailing bytes are therefore Unparseable, which fails safe toward migration.

The fail-safe taxonomy (built). Every body the shim cannot confidently classify fails safe toward migration, never toward pass-through, and that rule is written exactly once (Class::treat_as_migration). Cases now covered by tests: unparseable protobuf, a truncated or over-long gRPC frame, trailing bytes after the unary message, an empty transaction, the gRPC compression flag set, a grpc-encoding that is not identity (identity itself is correctly not treated as compression), and a declared message length that would overflow the frame bounds. A body the shim could neither read nor reproduce (over the 4 MiB buffer cap, or a client stream that broke mid-upload) is refused outright rather than forwarded unclassified.

Language and crates

Decision: Rust, static-musl, reproducible under StageX. It matches the ecosystem, reuses zebra-chain for the classifier and rustls for TLS, and yields a small enclave image. One dependency is worth naming because it changes the TCB: nym-sdk is pinned to a git tag and linked into the binary behind the mixnet-driver feature, which both deploy Containerfiles enable by default. The rest are of record in zeronym/shim/Cargo.toml.

The hub channel (ZIS -> ZIH)

The mixnet is the primary transport. Each side runs a linked nym-sdk client in-process and exchanges fixed-size frames. The clearnet path (an HTTP POST / to submit, POST /transaction to look up) still exists but is off unless ZIH_HTTP_SUBMIT asks for it, so a hub is mixnet-only by default. STEVE and the encrypt-to-hub-key layer are still designed.

Decision: submit is dispatch-only. The shim answers the wallet’s SendTransaction the moment the frame is dispatched to the mixnet, rather than blocking on the hub’s ack. The ack is a full mixnet round trip, ~10 seconds even healthy and minutes under gateway backpressure, and because neither side runs a validator it only ever confirmed that the hub queued the frame, never that the transaction was valid or in a mempool. The diverted path already relies on the wallet’s own confirmation-via-sync for both, so waiting bought latency rather than safety, and reported a queued transaction as failed at today’s throughput. submit returns as soon as the dispatch succeeds and fails closed when there is no hub address to send to. The asymmetry is deliberate: a lookup still awaits its LookupReplyV1, because a GetTransaction has no answer without one.

A consequence worth stating: reply SURBs are still attached and still sized for an ack nobody reads, so the hub spends them replying into a dropped receiver. They stay non-zero because a zero count would push the driver off the anonymous-send path, which is the property that matters; trimming them toward the anonymity minimum is a throughput follow-up.

  • The wire frames (built). SubmitV1 is magic ZNS1, a 16-byte correlation nonce, a length, and the transaction, zero-padded to exactly 64 KiB; AckV1 is magic ZNA1, the echoed nonce, and a disposition plus refusal code, exactly 64 bytes. LookupV1 and LookupReplyV1 carry GetTransaction the same way. Decision: fixed-size frames, so a passive observer learns nothing from the size of a submission, and every reply looks like every other.
  • Decision: no txid and no expiry on the wire. The hub derives both from the bytes it receives. An earlier design put them in the clear so the hub could dedup and schedule without parsing, but a txid handed across the transport is exactly a correlation handle, which is what the mixnet hop exists to destroy. Requests are correlated by the per-request nonce instead, which means nothing outside the pending-request table.
  • Attested, encrypted channel (designed). The ZIS would verify the hub’s attestation and derive a shared key (STEVE), then encrypt each migration to the hub key regardless of channel, so a compromised path yields nothing. Neither layer exists yet: on both transports the only encryption is what carries the frame.
  • Delivery guarantees. The shim rotates which hub address each submit targets, so load and a dead address spread across a multi-homed hub over successive sends. That is the whole of failover today: it is not primary-preference, and whether shims should prefer a primary or submit to every hub is open (review). What stays designed is holding a migration across requests: retrying on expiry slack, and a last-resort direct broadcast before expiry. The shim keeps no such state, so recovery rides on the wallet’s own resend.

TLS and certificate model

Decision: ACME-issued cert for the public domain, key born and held in the enclave. Wallets do standard TLS against zec.rocks:443, so the ZIS must present a valid CA-issued cert (drop-in, no wallet change). The key must be enclave-born (else the operator holds it and can MITM). So the enclave generates the key and runs an ACME client (Let’s Encrypt) to get the cert, completing the TLS-ALPN-01 or HTTP-01 challenge itself (it controls the endpoint). The key persists via the keymaker quorum; the cert renews via ACME. Let’s Encrypt certs are CT-logged, which is what the Auditor Role’s shadow-cert check relies on (see trust).

On Caution the platform supplies this rather than the shim: e2e_encryption { mode = "tls" } runs a Caddy inside the enclave that obtains the Let’s Encrypt certificate itself, so the private key is enclave-born and the operator never holds it, and upstream_protocol = "h2c" carries the gRPC in to the shim. The shim’s own rustls and ACME stack stays dormant there, as the vendor-independent path.

What the operator does

  1. Deploy the ZIS enclave, point the public DNS/URL (zec.rocks:443) at it.
  2. Configure the ZIS with the backing lwd’s internal address (e.g. 10.0.0.5:9067) and the hub’s address (a pinned TLS endpoint today; a Nym address under the Nym design). The backing lwd is unchanged and stays on its internal address.

Configuration

ZIS_LISTEN              # the address the shim serves wallets on
ZIS_BACKEND             # the operator's existing indexer, internal address
ZIS_BACKEND_TLS         # whether that hop is TLS
ZIS_HUB                 # hub address for the clearnet transport (repeatable)
ZIS_HUB_TLS             # the hub's expected TLS name
ZIS_HUB_NYM             # hub Nym address(es) for the mixnet transport
ZIS_NYM_GATEWAY         # entry gateway(s) to pin, repeatable; rotates on rebuild
ZIS_NYM_ROTATION_SECS   # sender-tag rotation interval
ZIS_NYM_TOPOLOGY        # localnet only: the harness-written topology
ZIS_CAUTION_ATTESTATION # own Caution's control-plane paths (default on)
ZIS_CAUTION_BOOTPROOFD_ADDR  # where the /attestation relay dials
ZIS_TLS_DOMAIN          # ACME: the served domain
ZIS_TLS_EMAIL           # ACME: contact
ZIS_TLS_PRODUCTION      # ACME: staging or production directory

No secrets are on disk: keys are quorum- or enclave-held. There is no network selector: the classifier is network-free, so nothing about the predicate depends on mainnet versus testnet.

Failure modes and correctness

  • Backing lwd down: pass-through requests fail as they would today; the ZIS is transparent, so this is the operator’s existing failure mode, not a new one.
  • Hub unreachable: the shipped single-hub shim submits within the request; if the hub cannot be reached it fails that submit rather than leaking the migration to the operator. Retry, fail-over across >=2 hubs on expiry slack, and last-resort direct broadcast are the designed multi-hub behavior, not yet shipped.
  • Restart / multiple instances: stateless, so nothing is lost. The hub dedups identical payloads by their sha256 hash.
  • Invalid migration -> false success: the ZIS returns success before the hub broadcasts, so an invalid tx fails silently at flush. Do stateless sanity at the ZIS (parseable, not already expired); full validity is the hub’s broadcast result (surfacing it to the client is out of near-term scope).
  • The operator learns that a client migrated (see honest limits): inherent; do not attempt shim-side mitigation.

Crate layout

zeronym/shim/src/
  main.rs         # config load, boot sequence, serve; spawns the mixnet tasks
  lib.rs
  config.rs
  proxy.rs        # h2 server, :path routing, reverse-proxy, control-plane paths
  intercept.rs    # SendTransaction + GetTransaction decode; gRPC frame + response synth
  classify.rs     # is_orchard_touching over zebra-chain (pure, unit-tested)
  hub.rs          # hub client over the clearnet transport
  tls.rs          # in-enclave keygen, ACME, keymaker-quorum persistence
  wire.rs         # SubmitV1 / AckV1 / LookupV1 / LookupReplyV1 frames
  nym.rs          # transport correlator, client-lifecycle supervisor, address failover
  nym_driver.rs   # the linked nym-sdk client (feature `mixnet-driver`)

Note no state.rs: the shim holds no per-migration state. There is also no attest.rs; /attestation is relayed by proxy.rs to the platform’s bootproofd rather than produced by the shim. Diversion landed as a branch in intercept::send_transaction on the fail-safe-folded verdict, with the upstream dial moved out of handle() so the connect happens only after a pass-through verdict; intercept::get_transaction routes lookups to the hub.

The zero-indexer-hub (ZIH)

The ZIH (earlier called zero-broadcaster) is an attested-TEE service that receives migration transactions from many shims, holds them in an in-RAM batch queue, and publishes the batch together on a strict block cadence, so no party can link a migration to the source IP that submitted it. It broadcasts through an indexer’s CompactTxStreamer (SendTransaction) over TLS, not a node’s JSON-RPC sendrawtransaction. Shims reach it over TLS on the deployed hop; running >=2 instances with failover is designed. At launch adoption the modal batch is 0 or 1, which proves the mechanics and content privacy but not batching anonymity (see honest limits).

Topology and process model

Many shims submit inward; the hub re-parses for telemetry, queues, and flushes every N blocks, shuffled, outward through its own indexer. It speaks three RPCs to that indexer, all over TLS: GetLightdInfo for the tip, SendTransaction to broadcast a batch, and GetTransaction for the detail lookups shims forward.

  • Enclave contents (TCB): the hub binary + rustls. It is lightweight, like the shim: no validator in-enclave. It connects OUT to an existing indexer (CompactTxStreamer over TLS) for chain tip and for broadcasting, not a node’s JSON-RPC.
  • Egress: TLS gRPC out to the hub’s indexer(s) for tip + broadcast. The mixnet build adds an in-process nym-sdk listener (no sidecar) and needs Nyx-RPC egress for ecash. Egress to the keymaker quorum is designed.
  • Supervisor: small PID-1 script, mirrors the shim’s.

Language and crates

Decision: Rust, static-musl, reproducible, same reasons as the shim. The load-bearing choice is that it speaks CompactTxStreamer gRPC to the hub’s indexer over TLS rather than a node’s JSON-RPC.

Inbound: receiving migrations

The hub is the server end of the shim’s channel. It binds an in-process mixnet listener and decodes the same fixed-size frames the shim sends. The clearnet POST / submit path is closed unless ZIH_HTTP_SUBMIT re-opens it, and when closed it falls through to the same 404 an unknown path gets, so a scanner cannot tell whether this hub would have accepted a submission at all. The POST /transaction lookup path is not gated.

  • Channel + auth (STEVE). The shim verifies the hub’s attestation and derives a shared key (STEVE); migrations are encrypted to the hub. Whether the hub also authenticates the shim (mutual STEVE) is an open decision (see review): one-way is enough for privacy, mutual would gate abuse.
  • Decrypt in-enclave. Only the attested hub software sees cleartext; the hub host operator (Caution) and the Nym path see ciphertext.
  • Re-parse, but as telemetry only, never as a drop reason. The hub parses with zebra-chain and re-runs is_orchard_touching so a disagreement with the shim is visible, and it stops there. This reverses an earlier reject-on-invalid rule, caught in zeronym/hub/REVIEW.md. The shim fail-safes for privacy: a body it cannot read cleanly routes to the migration arm precisely because it could not read it, so the transactions most likely to fail the hub’s parse are exactly the ones the shim deliberately diverted, and a hub that rejects them converts the shim’s fail-safe into a leak, handing an adversary who can characterise the parser skew an on-demand way to force a transaction back onto the direct-broadcast path. An unparseable payload is therefore queued and published; the indexer’s SendTransaction (which relays to its node) is the only authority on validity, and the cost of being wrong is one wasted batch slot. The permitted refusals are narrow and structural: authentication failure, a malformed frame, byte-budget exhaustion, and the expiry admission rule. Rate-limit per channel to bound resource use.

The batch queue

In-RAM (diskless enclave), keyed by the payload hash sha256(tx_bytes) for dedup, not the txid: under ZIP 244 two different byte strings can share a txid, and a submitter-chosen key would let an attacker suppress another’s entry, so the hash the submitter cannot forge is the right identity. Each entry carries that key plus the derived txid (telemetry only), expiry_height, tx_bytes, and received_at. The hub tracks the current chain height H from its indexer connection to schedule flushes and check expiries. Identical resubmissions collapse; this is also what makes cross-hub failover safe.

Flush and publish (the core)

  • Flush trigger: at every height that is a multiple of N (Decision: N = 20, about 25 minutes), and at no other time. The bound is a budget rather than a round number: N + mining_margin + delivery_lag <= min_wallet_expiry, or 20 + 4 + 6 = 30 against a 40-block expiry, asserted at startup so a later change to N fails loudly instead of quietly pushing traffic onto the direct-broadcast path. An earlier draft set N near 10 against Brave’s 20-block default, the ecosystem’s lowest (librustzcash 40, Zingo 100), which let the least generous wallet cap everyone; Brave is out of scope for v1 and the ask to them is 40. Doubling the window doubles the expected batch at no cost to any wallet, the cheapest improvement available to the batch-size problem in honest limits.
  • No early flush, and this replaces an earlier design. The trigger used to fire early if any queued migration’s expiry_height came within a safety margin. That is an attacker-operated flush clock: the hub’s re-validation is stateless, so one well-formed but consensus-invalid Orchard-touching transaction per block, at no cost, collapses every batching window network-wide and permanently. The urgency is instead made unreachable by admission control: accept a migration only if it provably survives the next scheduled flush (expiry >= next_flush_height(H) + mining_margin), and refuse it otherwise so the shim holds and retries rather than broadcasting. If nothing urgent can be admitted, nothing can ever be urgent.
  • Batches are triggered by time / block-height, never by transaction count: a count-based flush (say every 100 txs) would let an attacker submit 99 of its own migrations the instant it sees a target submit, isolating the target’s transaction in the revealed batch. Batch granularity must also line up with how wallets choose anchors and expiries (see the problem).
  • Publish “simultaneously.” On flush: take all pending migrations, shuffle the order (never leak arrival order), and submit them through the indexer(s) as close to simultaneously as possible (parallel SendTransaction), so they enter the mempool together and land in the same block window. An on-chain / mempool observer then sees N migrations appear together, unordered, from many shims. Decision: randomize order + parallel submit; do not drip them out.
  • Confirmation tracking (designed, not built). Move flushed migrations to an “awaiting confirmation” set; watch the chain until each is mined; re-submit if a tx is not seen within a few blocks (node dropped it, or a hub crash lost it). Drop from the set once confirmed or expired. Until this exists, a batch is on the network like any other submission once flushed, and nothing on either side tracks whether it was mined.
  • The anonymity set is the batch itself (cross-operator), so batch size is the key metric. At launch adoption (measured ~0.77 Orchard-touching tx/block, one to a few operators) the modal batch is 0 or 1, and a size-1 batch’s anonymity set is the transaction itself: the shuffle, the simultaneous publish, and the enclave prove content privacy and mechanics, not batching anonymity. The property is real but conditional on adoption, with no fix at v1. The hub therefore measures and exports its achieved batch size rather than asserting the property (see honest limits); hub-generated decoys are a costly last resort, not the primary lever.

The hub’s read-only endpoints

Two, both GET, both there because an attested enclave has no console.

  • GET /nym-address publishes the address every shim needs in order to submit at all. It is the one value in this system meant to be public, so publishing it costs nothing, and before it existed reading the address and proving the binary were mutually exclusive: the address reached only the log, and exposing the log meant debug mode, which disables attestation. It answers 503 until the driver’s first connect, deliberately, so an operator never pastes an empty string into a shim’s --hub-nym.
  • GET /healthz is liveness, for the same no-console reason.

Decision: neither endpoint reveals queue depth, batch size, or a count, and a test asserts that absence. Those numbers are exactly the achieved-batch-size measurement honest limits calls for, but served live and unauthenticated they would be an anonymity-set oracle: an observer could watch the queue fill and know how much company a migration had. Measurement belongs in operator telemetry, not on a public endpoint.

Decision: the hub’s Nym address survives a client rebuild. One credential store is built outside the rebuild loop and cloned into each rebuilt client, so identity key, encryption key and gateway registration are all reused. Without this every rebuild minted a fresh identity, which invalidated the address baked into every shim’s config, and the shim was observed doing it thousands of times. A counter forces a genuinely new identity after a bounded number of rebuilds, so a wedged client still has an escape hatch. Both arms are tested, address-unchanged and address-rotated.

Chain connection (tip + broadcast)

Decision: connect OUT to an existing indexer’s CompactTxStreamer over TLS, not a node’s JSON-RPC and not a validator in-enclave. The indexer endpoint is already published over TLS, which the enclave requires: without TLS on this hop the parent host reads every batch in the clear moments before it is public. Speaking CompactTxStreamer also means the hub broadcasts through exactly the interface wallets use, so nothing about a batched migration looks different from an ordinary submission at the point it enters the network.

  • Tip: poll GetLightdInfo for the height, keeping H current for flush cadence and expiry admission.
  • Broadcast: SendTransaction for each tx in the flush. Configure >=2 indexer endpoints for robustness. Honest cost: an indexer is a single funnel in front of a single node, so the “publish to every node” property is weaker here than direct multi-node broadcast, and a batch that entered only one mempool is one outage from never being mined. Broadcasting to many P2P peers directly (Nate’s point: a bigger anonymity set for the broadcast source), and over Nym to hide the hub’s own IP, are designed enhancements.
  • Detail lookups: the same indexer answers GetTransaction, which is how the shim’s intercepted GetTransaction is served without touching the operator.
  • The indexer connection is a hard dependency (no tip -> cannot schedule; no indexer -> cannot broadcast), so >=2 endpoints, and indexer-down is part of the hub’s failure handling.

Key management (hub key, STEVE, keymaker quorum)

The hub key is generated in-enclave and persisted by the keymaker quorum. Decision: a single shared hub key across all hub instances, which is what makes failover clean: a shim encrypts to “the hub key” and any attested hub can decrypt, dedup and publish, where per-hub keys would force re-encryption on failover and could strand a migration whose hub died mid-flight. Governance of that key, and the STEVE handshake the hub answers, are in trust.

Failover and multiple hubs

  • Run >=2 hubs, shared key. A shim prefers a primary hub (so batches converge there and stay dense) and fails over to a standby only when the primary is unreachable.
  • Dedup by payload hash within each hub. If failover causes a migration to reach two hubs, both may publish; the second on-chain submission is a harmless already-known duplicate. No cross-hub state sync is needed near-term (Decision: accept harmless duplicates over the complexity of a shared published-set).
  • The consortium’s multiple orgs are the natural operators of the standby hubs, which also starts decentralization.

Configuration

ZIH_LISTEN          # inbound TLS endpoint (default 0.0.0.0:8090)
ZIH_INDEXERS        # CompactTxStreamer endpoints over TLS: tip + broadcast (repeatable)
ZIH_INDEXER_TLS     # expected TLS name for those
ZIH_NYM             # bind the in-process mixnet listener
ZIH_NYM_GATEWAY     # entry gateway(s) to pin, repeatable
ZIH_NYM_TOPOLOGY    # localnet only
ZIH_HTTP_SUBMIT     # re-open the clearnet POST / submit path (default OFF)

The cadence is not configurable. FLUSH_INTERVAL_BLOCKS = 20, MINING_MARGIN = 4, MAX_DELIVERY_LAG = 6 and MIN_WALLET_EXPIRY = 40 are compile-time constants in hub/src/batcher.rs, and the budget inequality between them is asserted at startup, so changing one and getting it wrong fails the build or the boot rather than quietly pushing traffic onto the direct-broadcast path. There is no role setting either: primary-versus-standby is a design question, not a shipped one.

Failure modes and correctness

  • Indexer(s) down: cannot get tip or broadcast; with >=2 endpoints this is rare, but if all are down the hub cannot flush. Brief outages self-heal on the migrations’ expiry slack; a sustained one is what the designed last-resort direct broadcast covers.
  • Hub crash: the in-RAM queue and awaiting-confirmation set are lost (diskless). Recovery is designed, not shipped: standby hubs plus a shim that resubmits across failover on expiry slack. Hub-crash durability rides on the wallet’s own resend.
  • Expiry pressure: admission control refuses any migration that would not survive the next scheduled flush, so nothing urgent is ever queued. There is no early-flush escape hatch (that was retired as an attacker-operated flush clock).
  • Garbage / abuse: re-validate + rate-limit; optionally require shim attestation (see review).
  • Fee too low to mine before expiry: the fee is in the wallet-signed tx and the hub cannot change it; safety_margin gives mining headroom, but a badly-underpaid migration can still fail. That is the wallet’s responsibility, not the hub’s.

Crate layout

zeronym/hub/src/
  main.rs         # config, boot sequence, run the flush loop
  lib.rs
  config.rs
  server.rs       # inbound submit + lookup over the clearnet transport
  queue.rs        # in-RAM dedup queue keyed by payload hash; expiry tracking
  batcher.rs      # cadence trigger, admission control, shuffle, parallel publish
  chain.rs        # indexer connection: GetLightdInfo (tip) + SendTransaction (broadcast)
  tls.rs
  wire.rs         # the same frames as the shim, decoded on this side
  nym.rs          # the in-process mixnet listener
  nym_driver.rs   # the linked nym-sdk client (feature `mixnet-driver`)

There is no keys.rs and no attest.rs: the STEVE handshake, the encrypt-to-hub-key layer, and keymaker reconstitution are designed and have no code yet.

Boot, build, and attestation

Both services share the same enclave idioms: a static-musl Rust binary, a reproducible StageX build, running in an AWS Nitro enclave (attested, diskless). Both generate and hold keys in-enclave and bind a public key into the NSM attestation; neither writes secrets to disk. Reproducibility lets the consortium and third parties confirm the running binary is the reviewed binary.

Attestation binding. Each enclave binds the relevant public key into its attestation (shim: the TLS public key, not the Nym address; hub: the hub public key), so an auditor can check that cert-pubkey equals attested key. The candidate mechanisms and their status are in trust.

Shim boot sequence. (1) Key material: reconstitute the TLS keypair from the keymaker M-of-N quorum if one exists, else generate in-enclave and register with the quorum (persists across cold boots/upgrades; the private key never leaves the enclave). (2) Certificate: ensure a valid CA cert for the public domain via ACME, keyed to the enclave-born key. (3) Attestation: bind the TLS public key into the Nitro attestation; serve /attestation (or expose over Nym). (4) Hub session: STEVE-handshake each configured hub; cache the shared keys. (5) Backing lwd: open the upstream h2 connection(s); health-check. (6) Listen: bind :443, serve.

Hub boot sequence. (1) Hub key from the keymaker quorum (reconstitute; private key stays in-enclave). (2) Attestation: bind the hub public key into the Nitro attestation; publish /attestation (or over Nym) for the shim’s STEVE check + auditors. (3) Chain: connect to the hub’s indexer(s) over TLS; sync H via GetLightdInfo; verify SendTransaction works. (4) Inbound: bind the in-process nym-sdk listener and publish the hub’s Nym address. (5) Run the flush loop against H.

The open forks are collected in review.

Build and test

Shim. Unit: classify.rs against real vectors (the ironwood_activation.rs migrate tx, an Orchard-touching tx with no Ironwood bundle, a V5 Orchard spend, value entering Orchard, and a mainnet transparent tx), the correctness-critical piece. Integration: a mock backing lwd; assert every non-intercepted method and a pass-through SendTransaction reach it unchanged, and an Orchard-touching transaction (and every GetTransaction) is diverted to a mock hub, never reaching the backing lwd. Built so far: the classifier vectors (hand-written and generated), the wire-level transparency suite against both a hand-rolled h2c mock and a real tonic server, the logging assertions, a connection-counting backend that asserts the operator is never even dialled for a diverted transaction, the hub client, and the mixnet driver’s own channel, lifecycle and diversion tests. The fixtures were long generated with zebra’s own serializer, so no wallet-produced transaction had been classified; the mainnet migration run closed that gap by classifying and diverting a transaction a wallet actually produced. Enclave: the reproducible StageX build (done, CI-checked), the Nitro boot (done, live deploys since 2026-08-01), and the nonce-bound /attestation document (served; the manifest binds the deploy config including ZIS_BACKEND, while binding the TLS pubkey specifically remains open) are real, and a third-party operator has run the Auditor Role steps against a live enclave (PCR0/1 caveat in trust). Done end to end: a real mainnet Orchard-to-Ironwood migration has run ZIS -> hub -> batch -> published on the cadence, with the operator’s indexer never seeing the direct submit. What it does not yet prove is batching anonymity: at launch adoption the batch is size 1 (see honest limits).

Hub. Unit: the flush (N queued migrations -> one shuffled, parallel publish), admission control (a migration that would not survive the next scheduled flush is refused, never early-flushed), dedup (identical-payload collapse; harmless cross-hub duplicate), and re-parse as telemetry (a shim/hub disagreement is logged, never a drop reason). Integration: a mock shim submitting over a local channel and a mock indexer capturing SendTransaction; assert a batch is published together, shuffled, once per payload. Enclave: reproducible StageX build; boot in a Nitro enclave; verify the /attestation doc carries the hub key; a shim submits a migration over TLS. Done end to end (mainnet, TLS hop): a migration flowed shim -> hub -> batch -> published through the indexer and landed on-chain, unlinkable at the point of publish. Still unproven: unlinkability as batching (needs a batch larger than one, i.e. adoption), and the Nym hop against the public mixnet. The mixnet transport itself is proven end to end, shipped drivers on both sides, against a local mixnet the zeronym/nymnet harness starts.

Trust and honest limits

zero-indexer’s migration-privacy guarantee is designed as a chain of mechanisms, documented here end to end. Their status differs, and each is marked:

  • the Nym 5-hop mixnet that unlinks a shim from the hub, so the hub cannot tell which operator or region a migration came from (deployed: an attested shim and hub pair has run on the public mixnet since 2026-08-14);
  • AWS Nitro attestation over a reproducible StageX build, making the exact software in each enclave checkable rather than asserted (built: both shim and hub run as attested enclaves today, and a real mainnet Orchard-touching transaction has crossed the whole stack);
  • STEVE, Caution’s one-way enclave handshake, by which the shim verifies the hub before handing over a migration (designed; there is no separate encrypt-to-hub-key layer yet);
  • the keymaker / locksmith M-of-N quorum, a governance mechanism (separate from STEVE) that would persist keys across cold boots and upgrades and hand every hub the same shared key (designed);
  • the Auditor Role, which lets any independent third party confirm a public endpoint is really running the reviewed software, without trusting the operator (exercisable today for the application layer; see the PCR caveat below).

The same attestation makes the attested edge protections real: that the operator’s front-end is exactly the reviewed shim code, and its promise not to log requester IPs is verifiable, not merely trusted. See the problem for what that edge protects and review for claims still awaiting sign-off. The architecture owns the two trust-plane diagrams and the three nested encryption layers (the design end-state; today only the outer Nym layer is deployed); the roadmap covers PIR (V3) as the step that removes the hardware trust root.

What is trusted, and what is not. The trust root is the AWS Nitro platform and the people who review the open source and reproduce its build hash; the table under what this trusts has the party-by-party detail. The one entry worth flagging up front, because it changed: the mixnet client is linked in-process rather than run as a sidecar, so it is inside each enclave’s TCB.

The Nym transport (shim to hub)

Status: deployed. An attested shim and hub pair has run this transport on the public mixnet since 2026-08-14, the hub publishing its address at GET /nym-address. The clearnet dial remains in the code but is off at the hub unless ZIH_HTTP_SUBMIT re-opens it. What has not caught up is verifiability: the live pair’s published provenance does not currently reproduce (below).

Nym is used for exactly one hop: shim to hub. Wallets do not speak Nym; a naive wallet reaches the shim over ordinary TLS, and all query and non-migration traffic flows from the shim to the operator’s backing lwd over the local network. Only an isolated migration is routed over Nym, from the shim’s side to the hub’s. This is the same boundary STEVE observes: both live only on the shim-to-hub channel, never on wallet-to-shim.

The mixnet. Nym is a 5-hop mixnet. The encrypted migration is wrapped in a Sphinx packet and relayed through independent mix nodes, each peeling one layer and forwarding, so no node sees both source and destination. Cover traffic (steady dummy packets indistinguishable from real ones) makes the flow unlinkable in time and volume: the hub cannot tell which region or operator a migration came from, and a network observer cannot pick the real migration out of the cover. This breaks the “IP X was active at time T” to “on-chain migration at time T” correlation for naive wallets.

The mixnet client is linked, not a sidecar, and is therefore inside the TCB. Each side links nym-sdk and runs its own client in-process. An earlier design placed nym-sdk TcpProxy binaries outside each enclave as untrusted sidecars, on the argument that a migration was already encrypted to the hub’s key before it entered the tunnel. That design is superseded and the argument does not survive it: there is no encrypt-to-hub-key layer yet, and the client now sits inside the enclave rather than beside it. The honest consequence is that the TCB grew. The mixnet client and its dependency tree are part of what an auditor must review and what the reproducible build must cover.

What protects this hop instead, in the shipped code: every SubmitV1 frame is zero-padded to exactly 64 KiB and every AckV1 to 64 bytes, so frame size reveals nothing about the transaction inside; sends are anonymous with reply SURBs rather than an addressed tunnel; and the client’s sender tag rotates on a configurable interval. Mix nodes and the parent host stay untrusted and see only Sphinx traffic. Nym mainnet uses ticketbook ecash credentials, so the client needs Nyx-RPC egress (rpc.nymtech.net:443) to obtain them, a real operational dependency, not just an IP route.

Where the performance numbers come from. In the V2 rehearsal (2026-07-30), nym-proxy built from nymtech/nym carried real CompactTxStreamer gRPC over the live Nym mainnet mixnet against a live testnet node, end to end. Throughput is roughly 10x slower than clearnet (unary calls ~9 to 10 seconds, GetBlockRange ~19 blocks per second, latency-bound, first one or two calls warming up before steady state). Fine for migrations, which are not time-sensitive. One caveat on how far that carries: the rehearsal used the superseded proxy pair rather than the shipped in-process driver, so it bounds the mixnet’s cost rather than the current code path. Gateway backpressure, not credentials, turned out to be the throughput constraint, which is why the shipped clients pin their entry gateways.

TEE attestation (AWS Nitro)

Both binaries, the shim and the hub, run inside AWS Nitro enclaves. The enclave makes operator-blindness real and checkable. Nitro provides hardware memory isolation (parent instance and host cannot inspect enclave memory) and a hardware root of trust that signs a statement about the software inside. The design additionally assumes the enclave resists state rollback (rewind/replay) and memory-access-pattern observation; hardening for both is an open item (see review). See the problem for the detection-not-prevention framing this rests on.

Reproducible build, root hash. Both binaries are static-musl and built reproducibly with StageX (SOURCE_DATE_EPOCH=1); the recipes are zeronym/shim/deploy/ and zeronym/hub/deploy/. A reproducible build means anyone with the source produces a bit-identical image and the same measurement hash, so the chain runs: rebuild from source, get the same hash, match it against the hash bound into the attestation. An attestation alone proves only that some binary runs inside a genuine enclave; without reproducibility the design collapses into trusting whoever compiled it, which is exactly the party it refuses to trust.

Status: the source-to-hash link is proven for the recipe, and currently drifted for the deployed pair. The published hashes live in deploy/EXPECTED_SHA256 (machine-readable; they move when the source does, so this book quotes no literal).

Cross-machine agreement has been demonstrated: a native x86_64 CI runner, a local arm64 macOS host building linux/amd64 under emulation, and a third-party operator’s native x86_64 box all landed on the then-published hash from the same commit. Those differ in machine and in how the compiler was executed, so the agreement also rules out codegen that varies with runtime CPU feature detection, which two native builds would not have caught. Read that as a property of the recipe, not of any current value: each hash in force today is two cold builds on a single x86_64 runner, self-consistent but not cross-machine.

Three honest qualifications, all live as of 2026-08-14.

  • The check does not run on every push. Both reproduce workflows fire on pull requests touching the recipe and on manual dispatch, so a change landed directly on main outruns them.
  • Both published hashes are currently stale against the tip of main, and both jobs report DOES NOT REPRODUCE. The cause is known and deliberate: several recent changes each noted that they need a re-baseline before an attested cut, and that re-baseline has not happened yet. This is the third time the tripwire has caught a drift, which is the tripwire working, but it means the published values do not describe the current source.
  • The deployed pair’s provenance does not check out. The live shim’s PROVENANCE cites a source commit that is not public, so an auditor cannot even check out the tree it names; the live hub’s quotes a hash that its own cited commit does not produce. Until both are corrected, the enclaves are attested and running but not independently verifiable.

deploy/README.md carries the post-mortems. Building it settled the three requirements the PoC had surfaced:

  • A context spanning the vendored subtrees (solved). The crate path-depends on zebra/zebra-chain and zaino/packages/zaino-proto, so a per-crate context cannot work. An assemble script composes a repo-shaped context with git archive HEAD, which both resolves the path deps unchanged and structurally prevents the build from dirtying a vendored subtree.
  • zaino-proto pinned to default-features = false (solved, and double-locked). Its heavy default lets its build.rs find protoc and regenerate its committed protos inside the vendored subtree, and pulls a second zebra-chain from crates.io. Beyond the feature pin, the image simply ships no protoc, so the regeneration path cannot execute whatever happens to features. The tripwire stays cheap: git status --porcelain zaino/ zebra/ must be empty after a build.
  • A committed lockfile (done), pinning a parser that is not yet the node’s (still open). Cargo.lock pins the classifier’s parse. It is not identical to the node’s. The shim and hub are their own workspaces and now agree with each other (orchard 0.15.5, halo2_proofs 0.3.5, pinned across both lockfiles so a parse skew cannot surface as the shim rejecting a correct hub answer), but zebra’s binaries resolve orchard 0.15.3 and halo2_proofs 0.3.2. The lockfiles pin that remaining gap rather than closing it; closing it means the shim and hub joining a repo-root workspace, or all three pinning the same versions. The differential classifier test that would catch a real divergence is still deferred.

The second link is now demonstrated too. Both binaries run inside Nitro enclaves (Shielded Labs’ shims since 2026-08-01; the first third-party shim, in that operator’s own AWS account, on 2026-08-10), each serving a nonce-bound COSE_Sign1 attestation that chains to the AWS Nitro root. caution verify exercises the rebuild-and-compare loop against the published deploy context (app_sources in the manifest), and the application-layer measurement (PCR2) reproduces. One honest caveat, as of Caution platform 8e31ea7: the framework measurements (PCR0/1) do not reproduce, because Caution’s builder fetches its framework from a floating branch archive rather than the pinned commit, so caution verify reports FAILED on healthy enclaves until their fix lands. What an auditor can conclude today: a genuine, unmodified enclave whose application layer reproduces against the assembled deploy context, but which cannot yet be tied back to a public commit of this repository (the provenance defects above).

The hub reached that point later than the shim, and the parity is recent. Its assembler only gained an --app-source flag on 2026-08-13, and before that the hub manifest recorded no application source at all, so caution verify refused outright rather than reporting a mismatch: no hub enclave before that date was independently verifiable in any degree. The deploy script now also pushes the assembled tree to a public repository on an attested deploy, which is what gives an auditor something to clone.

In-enclave keygen and NSM binding. Each enclave generates its key at boot, inside the enclave (the private key never leaves) and binds the public key into the attestation produced by the Nitro Security Module (NSM). The attestation is a COSE_Sign1 document, signed up to the AWS Nitro root, carrying the enclave’s PCRs (the StageX root hash) alongside the bound public key. Verifying it proves both: the software is exactly the reviewed build, and the public key was born inside that software rather than handed in by the host.

The shim binds its TLS public key (see the shim for the ACME cert model), so an auditor can check the certificate a wallet sees is keyed to an enclave-born key. The hub binds its hub public key, the key shims encrypt migrations to. Attestation binding is achievable three ways on Caution’s platform (confirmed at V2): via the STEVE handshake, via the pubkey injected through metadata.json into user_data (which implies a persisted key), or via a new runtime arbitrary_data field Caution would add. The specific mechanism, and how /attestation is delivered for a zero-ingress service, are open for Caution (see review).

STEVE

Status: design. STEVE is not yet integrated; the shipped shim-to-hub hop is plain TLS terminating inside the hub enclave, with no separate encrypt-to-hub-key layer. What follows is the design.

STEVE (“Secure Transport Encryption Via Enclave”) is a Distrust protocol integrated into Caution, documented at distrust.co/blog/steve.html with source at git.distrust.co/public/steve. It is a second encryption layer that terminates inside the enclave, so that even when an outer transport terminates outside it, the plaintext exists only within the TCB.

Scope. Used only on the shim-to-hub channel, carrying the submit and lookup exchanges. Not used on wallet-to-shim (naive wallets speak plain TLS, which terminates inside the shim enclave on its own). STEVE is the middle of the architecture’s three layers.

One-way handshake: the client (shim) verifies the enclave (hub), not the reverse:

  1. The client checks the hub’s attestation and PCRs against the AWS Nitro root.
  2. It extracts the enclave’s Ed25519 identity key from the attested material.
  3. It sends an ephemeral X25519 public key.
  4. It receives the enclave’s ephemeral key plus an Ed25519 signature over that ephemeral key.
  5. It verifies the signature against the attested Ed25519 identity key.
  6. Both sides derive a session key by X25519 ECDH followed by HKDF-SHA256.
  7. Payloads are CBOR encoded and encrypted with AES-256-GCM.

STEVE runs as a reverse proxy on :8080: on the hub side the STEVE server decrypts inbound migrations inside the enclave; on the shim side the STEVE client performs the verification before any migration is sent. The Rust SDK is still in development (the JS SDK ships today), which is why implementing the handshake directly from standard primitives is a fallback (see review).

Two open STEVE items go to Caution: the exact wire form over Nym (does a STEVE session carry gRPC / h2, or a raw framed byte stream we frame ourselves), and mutual vs one-way (one-way suffices for privacy; making the hub also verify the shim’s attestation would additionally gate abuse). Both in review.

The keymaker quorum (separate from STEVE)

Status: design. The quorum is not built. At launch a single trusted entity (Caution) stands up the hub, and today a shim dials one hub with one key. What follows is the design.

Enclaves are diskless and ephemeral: a key generated in-enclave at boot is lost on every restart, and a software upgrade changes the measurement so a KMS-seal-to-PCR scheme would refuse to unseal the old key. zero-indexer needs long-lived keys anyway (a stable TLS key and address for the shim, one hub key every hub instance shares), so it uses a keymaker / locksmith M-of-N quorum.

What it is. An M-of-N key-custody mechanism spread across 3 to 4 organizations: the proposed consortium of Caution, Nym, Shielded Labs, and the Zcash Foundation. It reconstitutes a key inside a fresh, attested enclave across both cold boots and software upgrades (strictly better than sealing to PCRs, which breaks on upgrade). Private key material is only ever reassembled inside an attested enclave; no single org holds it.

What it persists. For the shim: the TLS keypair, giving the shim’s public URL a stable key and address across boots and upgrades, so the endpoint does not churn its identity and the ACME cert can renew against a stable key. For the hub: a single shared hub key across all instances. A shim encrypts a migration to “the hub key,” and any attested hub instance can decrypt, dedup, and publish it. Per-hub keys would force re-encryption on failover and strand a migration whose hub died mid-flight. With one shared key provisioned to every attested hub by the quorum, running two or more hubs with shim failover (see the hub) costs nothing in key management; a rare double-publish is a harmless on-chain duplicate, deduped by payload hash.

Governance trajectory. The quorum is also the long-term trust-distribution goal: the consortium collectively attests to key state and software integrity, so no single party (not even Caution) unilaterally controls the hub key. For launch this is staged: a single trusted entity (Caution) stands up the hub, with the multi-org quorum to follow. The consortium’s several organizations are the natural operators of the standby hubs, where decentralization of the hub itself begins.

The Auditor Role

The drop-in model creates a verification problem: a wallet connects to a familiar public URL (say zec.rocks:443) and needs to know it is really fronted by the reviewed shim enclave, not an operator who quietly kept the plaintext. The Auditor Role answers this without trusting the operator, and passes the assurance on: in practice a wallet developer acts as the trust proxy, audits once, and every user of that wallet inherits the result.

Steps for a public lwd endpoint:

  1. Fetch the endpoint’s TLS public key and certificate directly over HTTPS.
  2. Load its attestation. POST a nonce to /attestation and receive a COSE_Sign1 document, proving the private key was generated inside the enclave and carrying the root hash of the software there.
  3. Verify the PCRs against the AWS Nitro root. Confirm the attestation chains to the genuine Nitro hardware root of trust and the measured software matches expectations.
  4. Reproduce the build. Run the reproducible StageX build from source, obtain the hash, and confirm it equals the attested root hash, tying “the reviewed source” to “the software actually running.” This is the step the other three are worthless without. It has now been exercised end to end (above), with the caveat noted there: today it proves the application layer, and the framework measurements follow when Caution’s pinning fix lands.
  5. Check Certificate Transparency. Confirm no other currently-valid certificate exists for the domain. Otherwise an operator could present the attested, enclave-born cert to auditors while serving a different, non-enclave shadow cert to real users and MITM them. All Let’s Encrypt certificates are CT-logged, and the shim obtains its cert via in-enclave ACME precisely so this check is meaningful (see the shim). CT closes the cert-substitution gap the public-URL drop-in would otherwise leave open.

Concrete probes against a running enclave (the node IP here is an ephemeral testnet enclave):

curl -sk -X POST -H 'Content-Type: application/json' \
  -d '{"nonce":"00112233445566778899aabbccddeeff"}' https://<node-ip>/attestation
grpcurl -plaintext \
  -import-path <zaino>/packages/zaino-proto/lightwallet-protocol/walletrpc \
  -proto service.proto <node-ip>:8137 \
  cash.z.wallet.sdk.rpc.CompactTxStreamer/GetLightdInfo

The hub is audited the same way, and the shim’s STEVE handshake is the shim performing exactly this audit of the hub on every session before it trusts a hub with a migration. Independent auditors run the same steps out of band; reproducibility is what lets the consortium and third parties confirm that the running hub is the reviewed hub.

What this trusts, and what it does not

This table is the designed end-state; on the deployed hop the STEVE and hub-key layers are absent and each row’s why still holds by a shorter path.

PartyTrusted?Why
AWS Nitro manufacturer + platformYes (the trust root)Hardware root of trust signs the attestation; memory isolation blocks host peeking
Reviewers who reproduce the build hashYesThe StageX hash is only meaningful if someone recomputes it; CI does on every change, and a third party has matched it (above)
The in-enclave mixnet clientYesLinked in-process rather than run as a sidecar, so nym-sdk and its dependencies are inside the TCB and must be reviewed and reproduced like the rest
Operator running the shimNoSees only TLS that terminates in the enclave; Orchard-touching transactions never reach its backing lwd (see the shim)
Hub host (Caution at launch)NoSees only ciphertext (TLS terminating in-enclave today, STEVE in the design); cleartext exists only inside the attested hub enclave
Nym mix nodesNoSee only Sphinx packets, fixed-size and anonymously sent, and no node sees both source and destination
Indexer and nodes the hub broadcasts throughNoReceive only the final, wallet-signed, batched transactions, unlinked from any source IP

The honest consequence: this privacy trusts AWS and the hardware, not mathematics. That limit is developed under honest limits below; PIR (V3) is what removes the trust root (roadmap).

Honest limits

None of the limits below is a bug to be quietly patched later: each is a structural consequence of the design, and stating it plainly is required for launch and any blog post. The robust, volume-independent win is IP unlinking for the broadcast: the hub, not the requesting wallet, publishes the migration, so the on-chain transaction carries no link to the wallet’s IP. (The Nym hop additionally unlinks operator from hub.) The properties below are weaker, conditional, or out of scope. One scope reminder: the system protects the migration broadcast, not general queries. Which addresses a wallet looks up still pass through the shim to the operator’s backing lwd as today (query privacy is the deferred vision, see the roadmap); the one lookup that no longer does is GetTransaction, now answered by the hub so a migration’s follow-up query never reaches the operator.

The operator learns that a client migrated, not the amount. The shim forwards all traffic to the backing lwd untouched, with exactly one exception: an Orchard-touching transaction (a migration in the code’s terms, see the shim) is the one request it does not forward, diverting it to the hub instead (over TLS today; encrypted to the hub key and routed over Nym in the design). That asymmetry is observable, so an operator watching its own host’s traffic can infer that a given source IP submitted a migration, and roughly when, because that one request behaves differently from everything else. It does not learn which on-chain transaction or what amount: the hub batches the migration with every other operator’s clients and publishes them simultaneously, unordered. The residual is “IP X migrated something, around time T,” not “IP X migrated amount Y into transaction Z.” This inherent residual drives a firm decision: shim-side batching and shim-to-hub cover traffic are rejected. Both were proposed to hide the fact of a migration from the operator (batch a second time inside the shim, or send dummy shim-to-hub traffic), but a migration is by construction the one request the shim withholds from the backing lwd, so traffic analysis at the operator recovers the fact regardless of padding or delay. The genuine defense is hub batching (hides the on-chain transaction and amount), not a doomed attempt to hide the fact. A future Nym-aware wallet talking to the hub directly would sidestep it, but near-term wallets are naive TLS clients, so the residual stands.

The anonymity set is the cross-operator batch, and at launch volumes it is one. What hides which on-chain migration belongs to a client is the batch: the set of migrations from all shims that the hub publishes together in one flush. An operator never sees other operators’ clients, so the mixing happens at the hub, across the whole network. That makes batch size the key privacy metric for the timing property, and a singular batch is not a thin-period risk but the expected case. Sampling 144 blocks at mainnet tip 3,433,105 on 2026-08-01 gives 0.77 Orchard-touching transactions per block network-wide, roughly 37 an hour across every wallet in existence. Only the fraction flowing through participating shims reaches the hub, so with one to a few operators arrivals into the flush window are Poisson with a mean well under one, and the modal published batch is zero or one. For a batch of one the anonymity set is the transaction itself, and the shuffle, the simultaneous publish, Nym and the TEE are all irrelevant to that transaction’s timing. During the acute Orchard to Ironwood window density is natural, which is the whole reason to batch that mass, non-time-sensitive event; outside it the property thins out.

There is no version of this that code fixes. Holding longer needs a window the wallet expiry does not permit (the hub has the budget: the rest of it is spent on mining time and delivery lag); decoys reach only the orchard_value_balance == 0 subclass, because covering a genuine exit means spending a real legacy Orchard note and NU6.3 makes that stock finite, so they cost real on-chain value and are a last resort rather than the primary mechanism; and refusing to publish a lone migration would route it to a direct broadcast, a strictly worse leak. The batch is also publicly identifiable by construction: simultaneous submission to a small set of known nodes on a published cadence is exactly what an observer clusters on, so batch membership and size are readable from public data by anyone.

So the property is real but conditional, and the condition is adoption. State the threshold rather than the mechanism. The system should say what migration rate through participating shims is needed for a target batch size, publish the achieved distribution once running (the hub measures and exports it rather than asserting it), and decline to claim timing unlinkability until the measurement supports it. The single largest lever is not ours: transaction expiry is a wallet default rather than a consensus rule, so the flush interval is capped by whichever wallet in scope is least generous. Taking Brave out of scope for v1 already bought a doubling; raising the floor further, epoch-canonically, is what would make batch size a function of adoption instead of a fixed loss. That is a conversation with wallet teams, not a hub setting.

Broadcast is delayed, and the shim accepts before the chain does. The shim returns an accepted response to the wallet as soon as the migration is dispatched to the mixnet, which is earlier than it sounds: submit is dispatch-only, so at that moment the shim does not know the hub received the frame, only that it went out. The transaction is in no mempool until the hub flushes, up to roughly 25 minutes later (blocks target about 75 seconds; the flush is a strict 20-block cadence). A wallet polling in the mempool or confirmed? is answered by the hub, not the operator: the shim routes every GetTransaction to the hub, which reports a queued-but-unflushed migration at height 0 (the mempool sentinel) and serves its confirmed detail from the hub’s own indexer after flush. The one narrow gap is flush-in-flight: for the few seconds between the queue draining and the broadcast reaching the indexer a lookup can miss, though a resubmit in that window is harmless (deduped before flush, already-known after). Migrations are not time-sensitive, so the delay is acceptable by design. The batched class is wider than migrations: every Orchard-touching transaction is delayed, including a deshield straight to transparent. That costs little, because ordinary commerce lives in Ironwood (architecture has the argument). The real cost is UX, since the pending gap can confuse a UI or trigger a resend. A sharp edge, widened by the dispatch-only choice: because the shim returns success before the hub has even acknowledged receipt, both an invalid migration and one whose frame never arrived receive a false success and fail silently. The mitigation is stateless pre-validation (parseable, not already expired) at the shim and again at the hub, plus the wallet’s own confirmation-via-sync, which is what the design now leans on for delivery as well as validity. Surfacing a late hub-side failure to the client is out of near-term scope.

The shim is stateless; durability lives in the hub. The shim keeps no per-migration state, so a restart or a second shim instance loses nothing and leaks nothing. (An earlier design cached diverted txids in the shim to answer follow-up lookups; a restart forgot them, and the next poll fell through to the operator, the exact IP-to-migration link this system exists to prevent. It was deleted.) Recognising nothing, the shim routes every follow-up GetTransaction to the hub, so the only in-flight durable copy of an accepted migration is the hub’s queue, held until the next flush. Every failure path fails closed: an unreachable hub is an error to the wallet, never a fall-back to the operator’s indexer (the leak) or to a direct broadcast (off by default), and the wallet retries while the hub collapses identical resubmissions. The queue is RAM in a diskless enclave, deliberately, to keep the enclave TCB small, and it is not retained past flush, so a hub panic would lose an in-flight batch. That is why the hub code forbids panics outright rather than recovering from one; there is no confirmation-tracking layer on either side, and after flush a batch is on the network like any other submission. The queue and dedup engineering lives with the hub.

The trust root is AWS and the hardware, not math. The operator-blindness and hub-blindness guarantees are real and verifiable, but they rest on a hardware root of trust, not cryptography that stands alone. A wallet (or a developer acting as trust proxy) checks the shim’s and hub’s attestations against the AWS Nitro root, confirms the reproducible-build hash matches, and thereby knows the attested software is running and that keys were born inside the enclaves. That chain is only as strong as the AWS Nitro platform and the physical boundary of the hardware. If you do not trust AWS and the hardware, the near-term system gives no purely mathematical fallback. This is the specific gap the deferred PIR layer (V3) closes: PIR makes query privacy hold via math, hardware-independent, and closes the access-pattern and IO-pattern leaks a Nitro enclave structurally has when its state is parent-mediated at mainnet scale. TEE and PIR are complementary, not equivalent, with distinct failure modes (a TEE fails on hardware-manufacturer or physical-boundary compromise; PIR fails on cryptographic or software flaws), so the endgame uses both, defense in depth, with PIR as the trust-root-removal step (see the roadmap).

Hub resilience, and the concentration of trust. The hub must hold every migration in order to publish it, which makes a single hub both a liveness risk (an outage would stall migrations) and a concentration of trust (all cross-operator batching converges on it, run by Caution at launch). The design bounds this without eliminating the central point: two or more attested hubs on a shared quorum-provisioned key, queues idempotent by construction so a double delivery is a harmless on-chain duplicate, and a shim that fails closed (an unreachable hub errors to the wallet and never falls back to the operator, and direct broadcast stays off by default because it is a strictly worse leak than a retry). Everything there except fail-closed is designed rather than built; the engineering is in the hub.

The governing principle: an outage degrades privacy at the margin (a lost batch), while liveness rests on the wallet resubmitting rather than on any buffer inside the shim. No hub operator sees cleartext, since the hubs are attested enclaves. But the trust distribution is a goal, not a shipped property: at launch a single entity stands the hub up, consortium to follow.

How to report these limits. Lead with the robust, volume-independent claim (IP unlinking for the migration broadcast: the hub, not the wallet, publishes, and both ends are attested enclaves) and qualify the conditional ones: the batch-timing anonymity depends on migration density and can be size one; the operator can infer the fact of a migration though not its amount; the broadcast is delayed; the trust root is AWS and the hardware, not math. Do not describe a mechanism as shipped when it is only built: Nym is in the binaries but not in any attested deploy, and STEVE and the keymaker quorum have no code at all. Report the achieved batch size rather than an assumed one. Underclaiming here is cheap; overclaiming is the failure mode this chapter exists to prevent.

Roadmap

The near-term shim + hub system is the first step toward a fuller private indexer, not the whole of it. This chapter is the horizon behind it: layers that come after, not blockers on it.

The three-version vision

The long-term product is a full wallet-facing private indexer: not just broadcasts, but the queries a light wallet makes (which addresses it looks up) served privately. Three strictly additive versions, each adding one trust-reducing layer:

VersionAddsWhat it givesTrust root
V1indexer + NymQueries and broadcasts served over the Nym mixnet, so the indexer never sees a client’s source IP or timing.The indexer operator (who still sees query contents in cleartext).
V2+ TEE (RA-TLS)The indexer runs inside an attested enclave; the transport terminates inside it (remote-attestation TLS), so the operator is blind to contents and the wallet can verify this cryptographically.AWS Nitro and the hardware manufacturer.
V3+ PIRPrivate information retrieval serves queries without the server learning which record was fetched, closing the access-pattern leaks a TEE structurally has.Cryptography (hardware-independent).

The ladder walks the trust root down: operator (V1), hardware manufacturer (V2), only math (V3). V2 keeps Nym and adds the enclave; V3 keeps both and adds PIR as defense in depth, not a replacement (why it is not redundant is below).

Where the near-term system sits: the zeroith step

The shim + hub system is not V1. The ladder is full query privacy; the near-term system covers what the ladder does not urgently reach, the turnstile-crossing broadcast leak, driven by the mandatory Orchard to Ironwood migration. So it sits ahead of the ladder rather than on it: the zeroith step.

It is already a partial down-payment. Transaction-detail lookups (GetTransaction) are served by the hub’s own indexer, so the query leak is no longer entirely future, though address-level queries still reach the operator. It uses the same machinery on a narrower surface (both components are Nitro enclaves, and Nym is staged on the shim-to-hub hop), so building it de-risks the full indexer and vice versa. And it is a deliberate 80% first step: IP unlinking for the migration is the bulk of the practical privacy at stake in the acute window, while the rest of query privacy and the shield and non-Orchard deshield cases are the remaining margin, which costs far more to close.

Near-term status

This table is the one place status is maintained. Elsewhere the book marks a mechanism (deployed), (built, not deployed) or (designed) and links here. Three states, meaning: deployed runs in production today; built, not deployed exists in the binaries and passes tests but does not yet run in an attested deploy; designed has no code.

MechanismStatusDetail
Classify and divert Orchard-touching transactionsDeployedClassify before connect, so a diverted transaction never opens even a TCP connection to the operator’s indexer
Stateless shimDeployedNo per-migration state, so a restart or a second instance loses nothing
Hub queue, batch, flush on cadenceDeployed
GetTransaction served by the hubDeployedAddress-level queries still reach the operator
Reproducible StageX build, both binariesDeployed, currently driftedChecked on pull requests and manual dispatch, not on every push; as of 2026-08-14 both published hashes are stale against tip of main and both jobs report DOES NOT REPRODUCE. PCR2 only, see the gap below
Attested Nitro enclavesDeployedShielded Labs’ own indexers since 2026-08-01; first third-party operator 2026-08-10
In-enclave TLS terminationDeployedLanded 2026-08-05, so the TLS key is enclave-born
Nym transportDeployedAn attested shim and hub pair has run on the public mixnet since 2026-08-14; the hub publishes its address at GET /nym-address. The deployed pair’s provenance does not yet reproduce, see below
Multi-hub failoverPartly builtThe shim rotates which hub address each submit targets; holding a migration across requests does not exist
STEVE handshakeDesigned
Encrypt-to-hub-key layerDesigned
Keymaker quorum, consortium governanceDesignedLaunch stands the hub up under a single trusted entity
Confirmation tracking and re-submitDesignedNothing tracks whether a flushed batch was mined

On 2026-08-11 a real Orchard to Ironwood migration traversed the full stack on mainnet: held at the shim, batched at the hub, published on the cadence, with the operator’s indexer never seeing it. That run predates the mixnet deployment and used the clearnet hop.

The honest gaps that remain. That mainnet run proved the mechanics and content privacy end to end, but at today’s adoption the batch was size one, so it does not yet prove batching anonymity; that needs many migrations in one flush window (see honest limits). No migration has yet been observed crossing Nym in production.

The larger gap is verifiability, which currently lags capability. Attestation reproducibility covers the application binary but not the EnclaveOS base image and kernel (PCR0 and PCR1), so caution verify cannot establish the whole stack. On top of that, the live pair’s published provenance does not currently check out: the shim’s cites a source commit that is not public, the hub’s quotes a hash its own cited commit does not produce, and at tip of main both reproduce jobs fail. The enclaves are attested and running; an independent auditor cannot yet tie either one back to a public commit. Closing that is the near-term priority, and until it closes the system should not be described as independently verifiable.

V2 status: designed, platform-unblocked, transport-validated

As of the 2026-07-30 V2 sync, the three hardest gates of the indexer + Nym + TEE version are answered.

Designed. The full wallet-facing private indexer, serving queries (not just broadcasts) over Nym, terminated inside an attested enclave. The in-enclave Nym integration has since been built and deployed for the near-term system; what remains is the provenance and hash correction above. The TEE substrate, an attested Zebra + Zaino testnet enclave built reproducibly with StageX, is already live and synced to tip on testnet.

Platform-unblocked. Caution answered every platform question the design depended on. Attestation binding is achievable three ways (the STEVE handshake, the enclave pubkey injected via metadata.json into user_data, or a new runtime arbitrary_data field). Key persistence across cold boots and upgrades is solved by a keymaker/locksmith M-of-N quorum across 3-4 orgs, which survives upgrades where KMS-seal-to-PCR does not, and gives the service a stable address. Egress just works (broad NAT). Mechanics for all three are in trust.

Transport-validated. Rehearsed, not modeled: nym-proxy carried our actual CompactTxStreamer gRPC over the live Nym mainnet mixnet against the live testnet enclave, end to end, at roughly 10x slower than clearnet (unary calls ~9-10s, GetBlockRange ~19 blocks/s), which is fine for non-time-sensitive migrations. Nym mainnet uses ticketbook ecash credentials, so the client needs Nyx-RPC egress (rpc.nymtech.net:443). That rehearsal used a standalone proxy pair, so its numbers bound the mixnet’s cost rather than the shipped code path, which links nym-sdk into each binary.

The residual platform questions are the Caution agenda in review.

V3 (PIR): not redundant with the TEE

PIR is sometimes assumed redundant once queries run inside an attested enclave the hypervisor cannot inspect. It is not, for three reasons:

  • Different trust roots. V2 is private only if you trust AWS and the hardware; V3/PIR is private via math, hardware-independent, removing the manufacturer and platform from the trust base. PIR is the trust-root-removal step.
  • Different leaks. A Nitro enclave still has structural access-pattern and IO-pattern leaks when its state is parent-mediated at mainnet scale: the pattern of which records it touches is observable even when the contents are not. PIR closes exactly those, the server does not learn which record was fetched.
  • Complementary failure modes. A TEE fails on hardware-manufacturer or physical-boundary compromise; PIR fails on cryptographic or software flaws. Disjoint reasons, so the endgame uses both, PIR layered on top of the TEE rather than instead of it.

The PIR building blocks under consideration (SimplePIR/DoublePIR, FrodoPIR, YPIR, ChalametPIR) are in the glossary. PIR is a later layer, off the near-term critical path.

Deferred items

Real parts of the vision, held back so they never landed on the launch critical path. One documentation item belongs here too: a concern-by-concern coverage matrix against Taylor’s wallet app threat model, showing which entries zero-indexer closes and which stay the wallet’s (review states the claimed boundary).

The query-only / broadcast-only binary split. Taylor’s proposal: one attested instance proves it only answers queries and refuses broadcasts, a separate flavor only accepts broadcasts and refuses queries, so neither can correlate a client’s reads with its writes. The near-term shim already realizes a scoped version for turnstile crossings. The general split is deferred because wallets today assume a single endpoint, so requiring two is an adoption cost with no near-term payoff.

The attested Nym fleet. Caution’s planned global network of TEE-enabled Nym nodes (South Africa, Chicago, Brazil, Singapore, mirroring their DNS Cedar deployment), for a healthier public mixnet and broader adoption. Deprioritized because the near-term system routes Nym only between shim and hub, so users and wallets never touch Nym directly, and a better public Nym network is no longer the first thing to build.

The indexer-base decision (lightwalletd vs Zaino). The shim sits in front of whatever backend the operator already runs, so no base has to be chosen now. It re-emerges only when we build a first-party indexer for the deferred query-privacy product.

Two more, from launch-scope discipline. Full consortium key governance is the long-term trust-distribution goal, but launch stands the hub up under a single trusted entity (trust treats that concentration honestly). And Option A, the standalone privacy server, was set aside for the drop-in shim (architecture has the reasoning).

Open questions and review

The shim and hub designs are settled enough to build. What remains are cross-party items to close: platform unknowns for Caution (Anton), and a security-model checklist for Taylor and Zooko. Where the architecture or trust chapters own a mechanism, this chapter states the question and cross-links rather than restating it.

The gate moved. The threat-model doc was once the dependency that had to land before code was written. The shim and hub now ship as attested enclaves (roadmap), so the question is no longer may we build this but may we rely on these claims. The list below is that second gate, read against running code.

Open questions for Caution (Anton)

1. TLS termination (resolved)

The wallet’s TLS terminates inside the shim enclave. The platform default terminated it parent-side, which would have broken operator-blindness for exactly the naive TLS wallets the shim exists to protect; Caution shipped in-enclave termination on 2026-08-05 and it is verified end to end (see components).

2. STEVE wire form over Nym

STEVE is used only on the shim-to-hub channel. The transport question this section used to ask is now answered by the shipped code: there is no TCP tunnel and no h2 session over the mixnet. Each side’s linked nym-sdk client sends fixed-size SubmitV1 / AckV1 frames as anonymous messages with reply SURBs (see components). What remains open is only whether STEVE wraps those frames, and how.

The question for Anton: does a STEVE session wrap our fixed-size frames as records under the session key, or does STEVE expect to own the transport itself?

3. STEVE: mutual or one-way?

STEVE is one-way by default: the shim verifies the hub, extracts its key, and derives a session key (trust). That is enough for privacy. Mutual STEVE would raise the abuse bar but couple every shim to attestation provisioning and complicate onboarding a new operator, and one-way plus rate-limiting plus the hub’s own re-validation already bounds garbage.

The question for Anton: should the hub also verify the shim (mutual STEVE, to gate abuse), or accept from anyone with rate-limiting (one-way)?

4. Mixnet transport in an attested enclave (resolved, with one residual)

Both blockers this section used to name are closed, which is what let the transport deploy. Address publication: the hub serves its Nym address at GET /nym-address, and the identity now survives a client rebuild rather than being minted fresh each time. Gateway pinning: both sides accept a gateway list and the shim rotates through it on rebuild, which was the real lever, since the throughput constraint turned out to be gateway backpressure rather than any credential limit.

One operational residual, and it is a genuine platform question. request_gateway takes the gateway’s identity, while a locked egress allowlist is written in terms of its IP. The two are different namespaces, a mismatch fails closed inside an enclave with no console to debug it from, and because the shim rotates, an allowlist has to cover every gateway it might rotate onto rather than one.

The question for Anton: can egress be allowlisted by gateway identity rather than by IP, or is there a supported way to resolve a pinned gateway’s address at deploy time so the allowlist and the pin cannot drift apart?

5. Zero-ingress and attestation delivery

Today the /attestation endpoint is public on both shim and hub. On the shim it is now shim-served: the router claims the path and relays to the platform’s bootproofd rather than forwarding it to the operator’s indexer. That is what the Auditor Role fetches over HTTPS to verify an endpoint. But a hub ideally wants to be zero-ingress: nothing listening on the public internet, reachable only over Nym, presenting no public attack surface and leaking nothing about its location. Those goals collide: a true zero-inbound service cannot also serve a public /attestation behind a platform Caddy. So either the platform suppresses its public /attestation and Caddy, or the attestation is delivered inline over Nym (as part of, or alongside, the STEVE handshake). If it moves to Nym, the auditor and the shim’s own STEVE check need that alternate delivery path defined.

The question for Anton: can the platform suppress the public /attestation and Caddy for a true zero-inbound service, and if so, what is the supported way to deliver the attestation (inline over Nym) to the shim and to independent auditors?

6. The Rust STEVE SDK timeline

STEVE’s JS SDK ships today; the Rust SDK is still in development (see trust). Both endpoints we are building need the handshake in Rust: the shim’s hub-channel client and the hub’s STEVE server. Three paths:

  • Wait for the Rust SDK and integrate it directly.
  • Implement the handshake ourselves from the standard primitives documented in trust.
  • An RA-TLS fallback for the attested channel if STEVE itself is not ready in time.

The question for Anton: what is the Rust STEVE SDK timeline, and do you recommend we wait for it, implement the handshake directly now, or use an RA-TLS fallback?

7. Keymaker quorum walkthrough

The single shared hub key that all shims encrypt to, and that makes hub failover clean, is persisted and reconstituted by the keymaker / locksmith M-of-N quorum across the consortium orgs (Caution, Nym, Shielded Labs, ZF). The mechanism, and its separation from STEVE, is documented in trust. The open item is operational, not conceptual: a concrete walkthrough of how the single shared key is provisioned to multiple attested hub instances (so any hub can decrypt for failover) and reconstituted across cold boots and upgrades on the managed platform, which beats KMS-seal-to-PCR precisely because it survives an upgrade.

The question for Anton: walk us through provisioning the shared hub key to N attested instances and reconstituting it across boots and upgrades via the quorum.

Companion questions for Zooko and Nate

Design confirmations rather than platform unknowns; they live with the component that owns them, so they are cross-linked, not restated:

  • Publish path: the hub broadcasts through an indexer’s CompactTxStreamer over TLS (not node JSON-RPC). Open: the fan-out breadth (one indexer versus several, or direct Zcash P2P tx to many peers for a larger relay set) and clearnet versus over Nym for the hub’s own egress (see components).
  • Batch density versus failover: confirm primary-hub preference (converge for density, fail over only on outage) over spreading shims across hubs (see trust).
  • Hub re-validation (resolved to telemetry-only): the hub re-parses so a disagreement with the shim is visible, and never drops on that basis. Reasoning in components.
  • Flush cadence and safety margin: confirm the twenty-block flush and the four-block mining margin against real wallet expiry windows aligned in ZIP 318 (see the hub).
  • JSON-RPC front-ends: the shim intercepts only gRPC SendTransaction, so a migration submitted through a JSON-RPC sendrawtransaction front-end would bypass classification and leak. Wallets use gRPC, so this is out of near-term scope; confirm no operator front-ends migrations via JSON-RPC (see components).

Hosting and funding

A practical open item, separate from the technical unknowns: who hosts and funds the hubs at launch. Caution may cover a demo window; Shielded Labs may subsidize operators or run a donation drive; Nym (or Nym’s Coastline) may run the hub component. Launch logistics, not a design blocker.

For review by Taylor and Zooko

The security-relevant claims that need expert review before we rely on them. A living checklist, not a finished argument, aimed at the security model; the platform questions above are Caution’s.

The attested edge

  • Verifiable no-IP-logging (protection 2). Is the framing (“removes the passive, default leak, not a guarantee against an active operator”) fair and correctly bounded? See the problem.
  • The network-layer residual. On Nitro the parent host still sees the wallet’s source IP at the TCP layer, so a bad-faith operator can packet-capture and timing-correlate to re-link IP to query. Is that the correct and complete residual, or are there other cross-layer re-linking paths we are missing?
  • Tamper-proof front-end. We claim attestation plus Certificate Transparency lets a wallet or auditor verify it is talking to the real attested shim, not an impostor or a modified front-end. Does the CT check fully close cert substitution for the drop-in URL?
  • Reproducible-attestation gap (PCR0/PCR1). The application binary reproduces (its measurement is the attestation’s PCR2), but the EnclaveOS base image and kernel (PCR0, PCR1) are not yet reproducible end to end, and caution verify cannot currently confirm them (a known Caution limitation, so PCR2 is the measurement that carries weight today). Is PCR2-only reproducibility an acceptable interim, and what closes PCR0/PCR1? See roadmap.

The migration path

  • The honest residual. The operator learns that a client migrated (the one request not forwarded to its lwd), not the amount. Is that the complete residual, and are shim-side batching and shim-to-hub cover traffic correctly rejected as mitigations?
  • Anonymity set = the cross-operator batch. At low migration volume a batch can be size 1 (no anonymity), and the 2026-08-11 mainnet run classified and published a real wallet-built migration but at batch size one, so it validated the mechanics and content privacy rather than the anonymity set. Is hub-generated cover traffic the right (and only) backstop, and what batch size is “enough”? See trust.
  • The classifier, and what the proof of concept settled. is_orchard_touching is now a pure function over the vendored zebra-chain parser, and fail-safe for privacy is implemented rather than proposed: every body the shim cannot read cleanly routes to the migration arm, each case pinned by a test (components has the set). Left open: is that set complete, and does treating every unreadable body as a migration stay right now that diversion is destructive rather than a log line?
  • The classifier predicate (resolved). Presence only: is_orchard_touching(tx) := tx.orchard_shielded_data().is_some(), with the value balance logged as evidence and gating nothing. Zooko’s ruling; the closed-pool argument, the superseded > 0 predicate it replaced, and the measured cost of the widening are all in components. The consequence to accept: the batched class is wider than migrations, so an Orchard deshield is delayed by a flush window too.
  • Compressed SendTransaction: policy conflict. A compressed body never reaches the parser and lands in the fail-safe arm; because compression is negotiated, the shim normalizes the indexer’s advertised grpc-accept-encoding back to identity (see components for the lever this denies the operator). Is normalizing right, or should the shim strip the header and refuse a compressed SendTransaction outright? The cost of a fail-safe is only a delay: the hub still broadcasts a false-positive rather than rejecting it, since hub re-validation is telemetry-only (resolved above).
  • The interception set: is the backend survey complete? The shim intercepts on path alone and must stay a superset of every backend’s routing predicate; a fail-open bug of exactly that shape was found in the PoC and fixed (see components). The known residual is percent-decoding: the shim compares the path as received, so a backend that decoded before matching would route a request the shim passes through. Neither tonic nor lightwalletd does, but the survey covers only the backends we checked.
  • Expiry. Is the flush budget (components has the arithmetic and the wallet expiry values) sound against real mining latency, and is the delivery-lag allowance right given the mixnet’s measured round trip?

Trust and transport

  • STEVE. One-way, X25519 ECDH plus an Ed25519 signature, HKDF-SHA256, CBOR and AES-256-GCM. Is that understanding right? See trust. (Mutual versus one-way is question 3 above.)
  • The trust root. V2 privacy trusts AWS and the hardware, not math. Is the TEE-now-PIR-later (defense-in-depth, distinct failure modes) posture the right long-term answer? See trust and roadmap.
  • Nym. A 5-hop mixnet for the shim-to-hub path, with STEVE only shim-to-hub and wallet-to-shim being plain TLS terminating in the enclave. Any transport assumptions to challenge?

Detection design and the wallet requirement

  • Wallet anchor/expiry alignment. The protection requires ZIP-318-like wallets to choose identical anchors and expiry heights within a migration epoch; a latest-anchor wallet is re-linkable via the anchor (see the problem). Confirm with wallet authors, and align the hub’s batch granularity to how wallets pick anchors and expiries.
  • TEE hardening. The design assumes the enclave resists state rollback (rewind/replay) and memory-access-pattern observation. Both are open hardening items: are they achievable on the target platform, and what is the residual exposure if not?
  • The zec.rocks certificate. Its existing TLS cert is valid through October, so the scheme is ineffective for that domain until then without a fresh domain or key revocation (and wallets that check revocation). Decide the domain / revocation path.
  • Accepted non-defenses. Active wallet-tagging (a false chain or hold-back forcing identifiable anchors) and the transaction-size side channel (a distinctive migration size re-links via TLS ciphertext length) are out of scope by design. Confirm these are acceptable, or scope mitigations.
  • Operator-error alarms. Losing TEE state or an accidental certificate renewal is indistinguishable from an attack and will be announced as one. Confirm operators accept this and can run carefully (auto-renewal disabled, enclave state guarded).

Coverage against the wallet threat model

We claim zero-indexer targets the server-side and network-metadata concerns in Taylor’s wallet app threat model, specifically the surveilling-lightwalletd and compromised-lightwalletd adversaries, and not the wallet-app-local concerns (key and seed storage, memo integrity, dust resilience, wallet fingerprinting, supply chain), which the model itself lists as the wallet’s to address. Is that boundary drawn correctly?

Glossary and references

Component and term definitions, then the primary references. Where another chapter owns the deep dive, the entry states the meaning and links there.

Glossary

Anchor

A note-commitment-tree root a shielded transaction commits to, fixing which chain state it was built against. Wallets must choose aligned anchors within a migration epoch: a latest-anchor transaction is timestamped by its anchor, which re-links it in the revealed batch (the anchor-linkage attack, see the problem).

Anonymity set

For a batched migration, the cross-operator batch the hub publishes together. A batch of one gives no anonymity. See honest limits.

Attestation (Nitro / NSM)

A signed AWS Nitro Secure Module document binding an enclave’s in-enclave-generated public key to the root hash of the software inside it, verifiable against the AWS Nitro hardware root of trust. Both shim and hub publish one. See trust.

Auditor Role

Any independent third party verifying that a public endpoint runs the attested software without trusting the operator, via its attestation plus a Certificate Transparency check for no shadow certificate. Steps in trust.

Backing lwd

The operator’s own unmodified light-wallet indexer (lightwalletd or Zaino) that the shim fronts as a client. Block sync and pass-through queries reach it in cleartext as today; a diverted transaction and every GetTransaction do not. Those go to the hub’s own separate indexer, which handles transaction detail and batched broadcast.

Batch / flush cadence

The hub accumulates migrations (a batch) and publishes them together on a strict block cadence (a flush), every twenty blocks, about twenty-five minutes. The hub has the budget that fixes the interval.

Certificate Transparency (CT)

Public append-only logs of issued TLS certificates. All Let’s Encrypt certificates are CT-logged, letting the Auditor Role confirm no second, non-enclave certificate exists for a domain (no shadow cert to MITM clients). See trust.

CompactTxStreamer / SendTransaction

The light-wallet gRPC service (cash.z.wallet.sdk.rpc.CompactTxStreamer). The shim decodes exactly two of its methods, SendTransaction and GetTransaction, and passes every other method and stream through opaquely. See the shim.

Deshield

A turnstile crossing moving value from a shielded pool to the transparent pool. Batched near-term if it spends Orchard (it is then Orchard-touching), passed straight through otherwise.

Drop-in LWD

The shim looks like an ordinary light-wallet indexer to every wallet, so users need no config change and no new endpoint URL. This is why TLS must terminate inside the enclave and the shim must present a normal CA-issued certificate.

Expiry height

The block height past which a transaction is invalid and will not mine. The hub must flush a queued migration before its expiry height, capping the flush cadence (it cannot be widened to grow a batch). Per-wallet windows are aligned in ZIP 318.

Fail-safe (classification)

The shim’s rule that any SendTransaction body it cannot confidently read is routed to the diverted class, never passed through. A false negative is a privacy leak; a false positive is only a wasted diversion. The shim enumerates the cases and their tests.

Key consortium

Proposed multi-org governance of the enclave and hub keys: Caution, Nym, Shielded Labs, and the Zcash Foundation. Long-term trust-distribution goal; a single trusted entity (Caution) stands up the hub at launch, consortium to follow. See trust.

Keymaker / locksmith quorum

Caution’s M-of-N quorum across the consortium orgs, which would persist enclave keys across cold boots and upgrades and provision the single shared hub key to every hub instance. Designed. Separate from STEVE, which is a per-session handshake rather than key custody. See trust.

Migration

The Orchard-to-Ironwood crossing that sets the deadline: the acute, mass, non-time-sensitive event. Also the legacy name the code (Class::Migration, treat_as_migration()) still gives to the whole batched class, which is wider than a literal migration (see Orchard-touching transaction). See the problem.

Migration epoch

The batching window over which wallets choose identical anchors and expiry heights and the hub reveals migrations together in shuffled order. Batches are time or block-height based, never transaction-count based (else an attacker floods its own migrations to isolate a target’s). See the problem and the hub.

Nym

The 5-hop Sphinx mixnet with cover traffic, the near-term transport for the shim-to-hub hop: it makes that traffic unlinkable, hiding which operator or region a migration came from. Both binaries link nym-sdk and run a mixnet client in-process (no proxy sidecars). An attested pair has run it on the public mixnet since 2026-08-14. See the architecture and the status table in roadmap.

Orchard-touching transaction

Formerly “Orchard exit.” Any transaction carrying Orchard actions, whatever its value balance or destination: the class the shim diverts and the hub batches. The value balance is evidence, not the test. Zooko’s rule; the closed-pool argument is in the shim.

PCRs (Platform Configuration Registers)

The measurement values in a Nitro attestation that fix the enclave’s software identity. An auditor (or, in the design, the shim’s STEVE check) verifies them against expected values and the AWS Nitro root before trusting an enclave. See trust.

PIR (Private Information Retrieval)

Cryptographic query privacy: a client retrieves a record without the server learning which. The hardware-independent, math-based trust root planned for long-term V3, complementary to the TEE (distinct failure modes). Candidate schemes: SimplePIR/DoublePIR, FrodoPIR, YPIR, ChalametPIR. See the roadmap.

RA-TLS

Remote-attestation TLS, a fallback transport binding the enclave’s attestation into the TLS handshake. An alternative to STEVE for the attested channel. See open questions.

Shield

A turnstile crossing moving value from the transparent pool into a shielded pool (including coinbase or mining-reward shielding). Passed through near-term unless the same transaction also spends Orchard; privacy-positive already, since the transparent side is public.

StageX

The reproducible, deterministic build system (SOURCE_DATE_EPOCH=1, static-musl) used to build the shim and hub binaries, so an auditor can rebuild from source and match the software root hash bound into the attestation. See trust.

STEVE

“Secure Transport Encryption Via Enclave,” a Distrust protocol in Caution: a second encryption layer terminating inside the enclave, used only shim-to-hub, one-way (the client verifies the enclave). Designed, not yet integrated: the shipped shim-to-hub hop is plain TLS with no separate encrypt-to-hub-key layer. Handshake and primitives in trust. Separate from the keymaker quorum.

SubmitV1 / AckV1

The shim-to-hub wire frames. SubmitV1 is magic ZNS1, a 16-byte correlation nonce, and the transaction, zero-padded to exactly 64 KiB so every submission is the same size; AckV1 is magic ZNA1, the echoed nonce, and a disposition, exactly 64 bytes. LookupV1 / LookupReplyV1 carry GetTransaction. No txid and no expiry travel on the wire: the hub derives both, because a txid would otherwise be a correlation handle. Over the clearnet path, when ZIH_HTTP_SUBMIT re-opens it, the shim instead POSTs raw bytes. The encrypt-to-hub-key inner layer is designed, not built. See the shim.

TEE / AWS Nitro enclave

Trusted Execution Environment. Both shim and hub run as attested, diskless AWS Nitro enclaves, making operator-blindness and hub-blindness checkable rather than merely trusted (the application layer reproduces today; the framework measurement does not yet, see trust).

Trusted Organization (TO)

The party that operates the hub and, for detection, verifies the shim’s setup attestation, makes anonymous requests to confirm the attested key is served, monitors Certificate Transparency, and publicly announces detected attacks. The design is detection-based, not prevention. See the problem.

Turnstile crossing

Any transaction moving value across a value-pool boundary: a deshield, a shield, or a cross-pool migration. The classifier detects every crossing; near-term the system batches every crossing that touches Orchard (see Orchard-touching transaction). See the shim.

Value balance

The signed net value leaving a shielded pool (positive when value leaves that pool). It is evidence, not the predicate: the shim diverts on the mere presence of Orchard actions, so the Orchard, Ironwood and Sapling balances are all logged to show where value went while gating nothing. See the shim.

zero-indexer

The Shielded Labs privacy product for Zcash light wallets, formerly named Zeronym. Two pillars: zero-leak indexing and the Nym mixnet. See the introduction.

zero-indexer-hub (ZIH)

The attested-TEE batcher (earlier zero-broadcaster) that accumulates diverted transactions from many shims, holds them in-enclave, dedups identical bytes, and publishes them together on a strict block cadence through its own hub indexer over CompactTxStreamer. It also answers a wallet’s GetTransaction for a queued or flushed migration. Running two or more instances with failover, and the encrypt-to-hub-key layer it decrypts in-enclave, are designed; the payload arrives as fixed 64 KiB frames over the mixnet, or as raw bytes inside TLS on the clearnet path when ZIH_HTTP_SUBMIT re-opens it. See the hub.

zero-indexer-shim (ZIS)

The lightweight, attested-TEE, stateless reverse proxy an operator deploys behind their existing public URL. It passes traffic through to the backing lwd, except that it classifies every turnstile crossing and diverts Orchard-touching transactions to the hub, and routes every GetTransaction to the hub as well (so a migration’s follow-up lookup never reaches the operator). Encrypting each diverted transaction to the hub key is designed; the Nym route is deployed. See the shim.

References

STEVE.

  • STEVE blog post: https://distrust.co/blog/steve.html
  • STEVE source repository: https://git.distrust.co/public/steve

Zcash light-client protocol and the leak.

  • ZIP 307, Light Client Protocol for Payment Detection: https://zips.z.cash/zip-0307
  • ECC, Zcash reference wallet light-client protocol: https://electriccoin.co/blog/zcash-reference-wallet-light-client-protocol/
  • ZecSec (Taylor Hornby), Making Zcash light wallets faster and more private: https://defuse.ca/zecsec/making-zcash-light-wallets-faster-and-more-private.htm

Migration timing.

  • ZIP 318 (migration expiry alignment): https://zips.z.cash/zip-0318

Nym.

  • Nym mixnet: https://nymtech.net

Distrust and Caution.

  • Distrust (Caution platform, StageX, STEVE): https://distrust.co

PIR (private information retrieval). Candidate schemes for the deferred query-privacy layer are named in the roadmap; they are a reading list to assemble, not yet a set of chosen citations.