Skip to content

Anonymous access

A client can show it is entitled to a session in two ways. With a wallet, it signs a message and the Control Plane knows whose session it is. Anonymously, the Control Plane learns one thing only: whoever connected is somewhere in the subscriber set.

This chapter explains how the anonymous path works and what a client has to do for it. The proof itself is built step by step in building a proof.

A subscriber holds one secret, and from it one identity per slot of their Orbit. Their commitments, hashes, sit in SubscriberRegistry next to everyone else’s.

The secret comes from a wallet signature over a fixed string:

Singula Anonymous Identity v1

The string never changes. Change one character and you get a different identity, which to a user looks like losing their Orbit.

Slot 0’s identity comes from the signature; slot i’s from slot 0’s secret:

secret_0 = "0x" ‖ hex(keccak256(utf8(signature)))
secret_i = "0x" ‖ hex(keccak256(utf8(secret_0) ‖ utf8("/slot/" ‖ i)))

One signature therefore yields every identity on any device, and a renewed Orbit registers the same ones.

Derivation is deterministic, so the identity travels with the wallet: the same wallet on a new machine signs the same string and gets the same secret back. There is nothing to export and nothing to back up.

That relies on the wallet’s signature being deterministic. RFC 6979 makes it so and wallets behave that way in practice, but no standard promises it. So a client should check the commitment it derived against the one registered on chain before relying on it: a wallet with a random nonce would quietly produce a different identity.

The secret never leaves the machine. Only proofs built from it travel.

Anonymity here is a headcount: you are hidden among everyone who has registered a commitment. Check the current size with SubscriberRegistry.totalCommitments() before relying on it.

One commitment per slot: an Orbit with three slots registers three, each good for one session at a time. That they belong to one Orbit is public, like the rest of registration; which of them a session uses is not. So count the set in Orbits rather than in commitments.

Registering emits a Registered event. The Control Plane sends it through GasStation, and its gas was paid for with the purchase: your signature is enough, no ETH needed.

The best moment to register is activation, not the first time an anonymous session is wanted. SubscriptionNFT lets the activation name the commitments and registers them in the same transaction, so the wallet signs once, the set gets its member sooner, and the anonymous path is ready the first time anyone reaches for it. Wanderer always activates this way.

Removal zeroes a slot instead of closing the gap, so positions never shift. A commitment stays reserved to whoever first published it, which lets a returning subscriber come back with the same one.

The client proves it knows the secret behind some commitment in the set and publishes a nullifier, so the same entitlement cannot be used twice. Which commitment, the proof does not say.

The scope packs three values:

scope = (cpStarId << 96) | (epoch << 32) | index

All three sit in the scope rather than being split between scope and message. Semaphore derives the nullifier from (secret, scope) and the message does not take part, so a slot put in the message would give every slot the same nullifier.

In this order, cheapest first:

  1. The slot. The index must be below the epoch’s quota.
  2. The set. The root must be one it knows: the current one, or one replaced moments ago. A proof against a set it does not know cannot be judged at all.
  3. The claim. The nullifier must not have been used this epoch.
  4. The proof. It is checked against a scope the Control Plane builds itself from its own Star id, its own current epoch and the declared slot. The epoch in the request is ignored.
  5. The epoch boundary. If the check fails within the first five minutes of an epoch, the previous epoch is tried — just that one. A proof built seconds before the change is not cheating.
  6. The budget. The slot must not have used up its node time.
  7. Spending, last of all. The nullifier is burned only once everything above has passed, so an invalid proof never costs a slot.

A refusal says which of these failed. The exact texts are in troubleshooting.

A slot is the index within an epoch, and each slot has its own nullifier. By default an Orbit gets 25 openings per epoch at each Control Plane — the Star id in the scope makes the count per Control Plane — and each slot carries a budget of 2 hours of node time. Both numbers are network parameters, the same everywhere; see limits.

Counting openings alone would be wrong: fifty short sessions cost the network far less than one that runs all day. The budget is the other half, and the two together are what one Orbit can use at one operator in an epoch.

A Control Plane cannot tell a client which of its slots are used — telling one subscriber from another is exactly what it is built not to do. So the client keeps count itself, per Control Plane and epoch, and moves to the next slot when one comes back as used.

The quota is counted per operator, so an Orbit that used it up at one Control Plane simply starts again at the next. Permissions are what limit concurrent sessions across the whole network.

A permission lets its holder run one session at a time. While the session runs, the permission is held in a set every Control Plane shares. It is signed blind: the issuer signs a value it never sees, so it cannot recognise the permission later, at its own Control Plane or anywhere else. The shared set shows how many permissions are in use, never whose.

An Orbit has a fixed number of permissions per window, where a window is an epoch. They are numbered from 0. The issuer sets the count and commits it on chain with its key; today it is 5.

Each number is taken on its own and only once per window anywhere in the network: the proof for a number gives the same nullifier at every Control Plane, and Control Planes tell each other which numbers they have issued. Devices that share an Orbit take different numbers. Any Control Plane can issue a number not yet taken, and a permission works with every operator whoever signed it.

Take permissions early, from a Control Plane you have just found healthy, rather than at the moment a session is needed. Taking one goes like this:

1. Ask for the issuer’s key. Send GetIssuerKeyRequest. Back come the DER public key, the issuer’s Star id, the window and the count.

2. Check the key against the chain.

VoucherKeyRegistry.getEpochKey(issuerStarId, epoch)
returns (bytes32 keyHash, uint32 denominationGB, uint64 setAt, bool issuerActive)

Require issuerActive, require denominationGB to equal the count you were given, and require keyHash to equal keccak256(DER ‖ denominationGB), with the count as four big-endian bytes. computeKeyHash does the hashing for you.

Read the registry at the address from SingulaProtocol, not one a Control Plane names: checking a key against a commitment chosen by the same party proves nothing.

A key is published a whole epoch ahead and can never be written for the current one. That is what makes “the same key everyone else got” something a client can check.

3. Blind it and ask. Generate a random 32-byte secret, blind it under the issuer’s key with RFC 9474 RSABSSA-SHA384-PSS-Randomized, and send it in a TakePacketRequest with the index you want and a proof built against the permission scope for that number. One blinded message per request.

If the reply has index_taken set, that number is already gone this window — taken by another device, or by this one earlier. Ask for another. Try numbers in random order, so two devices of one Orbit rarely reach for the same one.

4. Unblind it. The answer unblinds into a signature over the prepared message — randomizer ‖ secret, 64 bytes — because the randomized variant signs the prepared form, not the secret. Keep the pair together with its number and window: a client that kept only the secret would hold a signature it cannot present.

Check the signature as you unblind it, not when a session is wanted.

Put the Permission in the SessionRequest next to the AccessProof. Both are required: the proof says you are a subscriber right now, and the permission says how many sessions the Orbit can hold at once. A request with a proof and no permission is refused.

Send the issuer’s public key along with it. The Control Plane checks that key against the on-chain commitment rather than trusting it, so including it saves a round trip and costs nothing.

A permission is held for as long as its session runs. The Control Plane holding the session renews the hold every 20 seconds and reports the permission to every peer, and each peer keeps it for a minute after the last report. When the session ends — closed by the client, closed after about five minutes without a checkpoint, or past its deadline — the reports stop, and within a minute the permission is free everywhere.

So a client should close its sessions when it is done with them: send SessionEnd, signed with the session’s checkpoint key (see closing a session). A client that simply vanishes keeps its permission busy until the Control Plane gives up on the session.

Renewing a session within its window keeps the same permission. Renewing into the next window needs a permission for that window.

A shared set. Nullifiers need no coordination between operators; permissions do, because the whole point is that one held anywhere is held everywhere. Control Planes pass each other one-way digests of the permissions in use, and repeat them while a permission is held. The permissions themselves never travel between them.

With the Control Plane’s id in the scope, one subscriber produces different nullifiers at different Control Planes. Two operators comparing lists find nothing in common, and nullifiers never reach the chain.

Permissions are the deliberate exception. They are held in a shared set, because a limit on concurrent sessions means nothing if each operator counts on its own. What that set holds is one-way digests of values the client chose and the issuer signed blind, so comparing it across operators shows how many permissions were used and nothing about whose.

The same digest is named on chain: each interval a Control Plane pushes to EpochAttestations carries the permission it ran on. How long a permission was in use is public; whose it is stays hidden.

And nothing is ever issued for the membership proof: the client builds it from a secret that never travels, so no Control Plane is there when the entitlement comes into being. In practice a cold wallet signs once, when the secret is derived, and never per session.

  • The first node sees your IP. Anonymous access hides who you are from the Control Plane, not where you connect to a node from.
  • The set can be small. The proof is no weaker for it, but the crowd you hide in is. Check totalCommitments().
  • Registering is public. The Registered event names the Orbit’s token, and through it the wallet that holds it. Since Wanderer registers every Orbit at activation, the event says that a wallet holds an Orbit; which sessions are theirs stays hidden.
  • The two tiers do not mix in one session. A signature names you; a proof does not.
  • Timing is not hidden. In a set of two, who connected when speaks for itself.

What each participant sees is laid out in who sees what.