gRPC API
You do not register or get a key to use Singula. A Control Plane address, the .proto files and a wallet are enough.
There is one access rule:
Anything that touches a session, money or a node’s address needs a signature: from a wallet, from an anonymous proof, or from the session’s own key.
The network has no API keys, on purpose. Somebody has to issue a key, store it and be able to revoke it, and that somebody ends up with a list of everyone who uses the network. A signature does the same job and leaves nothing behind.
Every method and field is listed in the API reference, built from the same .proto files this site serves. This chapter explains how to use them and what the network expects of your client.
Four services on one port
Section titled “Four services on one port”A Control Plane serves four gRPC services on one port, 9090 by default:
| Service | For |
|---|---|
ClientService | client apps: sessions, checkpoints, buying an Orbit |
AdminService | dashboards and monitoring: health, the list of Stars |
NodeService | nodes |
FederationService | Control Planes talking to each other |
Your client needs the first two.
No Control Plane address is built in anywhere. Each one publishes its host and port in CPEndpointRegistry, and a client finds the registry through SingulaProtocol.snapshot():
getActiveEndpoints() returns (uint256[] starIds, string[] hosts, uint16[] ports, uint64[] updatedAts)An operator can change the port at any time, so read the registry when you connect rather than once at build time. A port of 0 means the default, 9090.
What works without a signature
Section titled “What works without a signature”A client needs a few methods before it has signed anything, so these are open to everyone:
ClientService.GetNetworkInfo— prices and token addresses, for the screen where someone buys their first Orbit.ClientService.ListNodes— the nodes this Control Plane can hand out: Star id, country, load and measured latency. Useful when the client picks its own node.ClientService.GetIssuerKey— the key this Control Plane signs permissions with. The client checks it againstVoucherKeyRegistrybefore relying on it.AdminService.GetHealth— whether the Control Plane is up.AdminService.ListNodes— which Stars exist and whether they are alive, all of it on chain anyway.
A signature on these would protect nothing, since wallets are free and unlimited. It would only keep people who have not signed in yet from finding a Control Plane.
Everything else needs one of three proofs:
- a wallet sign-in — for sessions, the country list and the gas-free transactions;
- an
AccessProof— instead of a wallet, when a client opens a session or takes a permission anonymously; - the session’s checkpoint key — for anything done to a session that already exists.
TrafficCheckpoint,RenewSessionandSessionEndsigned with it are accepted over any connection.
Node addresses always need a signature. The open ListNodes names each Star and says whether it is alive, but leaves out the IP, the port and the REALITY key. Node addresses are not on chain, and a Control Plane handing them to anyone would make the network easy to map. An owner gets the addresses of their own nodes by attaching AdminAuth, an EIP-191 signature that proves ownership.
GetNetworkInfo is a convenience, not a source of truth. Its addresses come from the very Control Plane you are talking to. Anything your client bases a decision on should come from SingulaProtocol, over an RPC endpoint you chose yourself.
Connecting and signing in with a wallet
Section titled “Connecting and signing in with a wallet”ClientService is one bidirectional stream:
rpc Connect(stream ClientMessage) returns (stream ControlPlaneMessage);The client’s first message is ClientAuth, carrying a signature over this text:
Singula
Sign in to open a session on this Control Plane. The signature provesyou hold a subscription; it is not sent anywhere else.
This authorises no transaction and cannot move your funds.
Control Plane (Star id): <star id>Nonce: <nonce>Issued at (unix): <timestamp>Valid for: 7 daysWhat matters about the signature:
- Format. EIP-191
personal_signover exactly this text. The Control Plane rebuilds it and recovers the address from the signature, so a stray space or a different line ending breaks the sign-in. - Recipient. The text names the Star id of the Control Plane you are connecting to, and a signature naming another is refused. That way a signature works at one Control Plane only, and it cannot present it to another in your name.
- Lifetime. The signature is valid for seven days. Keep it and reuse it when you reconnect, rather than asking the wallet every time.
The text is written in plain words because it is all a wallet shows the user. People should understand what they sign.
A Control Plane that does not know its own Star id yet, right after starting for instance, refuses sign-ins.
Signing in without a wallet
Section titled “Signing in without a wallet”An anonymous client sends no ClientAuth. It puts two fields straight into its SessionRequest instead:
access— anAccessProofshowing that it belongs to the subscriber set;permission— one of the Orbit’s permissions for the current window.
Both are required. A request without a permission is refused.
message AccessProof { uint64 epoch = 1; bytes merkle_root = 2; bytes nullifier = 3; uint32 index = 4; repeated bytes points = 5; // Groth16, 8 elements}How the Control Plane checks the proof:
- Epoch. It does not take the epoch from the request. It rebuilds the proof’s scope from its own Star id and its own current epoch, so a proof is good at one Control Plane in one epoch.
- Slot.
indexis the quota slot within the epoch. Each slot has its own nullifier, so one Orbit can open several sessions per epoch. - Merkle root. The current root is accepted, and so is one that was replaced moments ago. Any registration in the set moves the root, including a stranger’s, and without that leeway a proof built a minute ago could already fail.
Building the proof and taking a permission are covered in anonymous access and building a proof.
While the session is alive
Section titled “While the session is alive”The client sends a SessionRequest and gets SessionCredentials back. The node itself encrypts the credentials, by ECDH to your session_public_key, so the Control Plane passes them on without being able to read them.
From then on the client has three jobs: report, renew, and close the session when it is no longer needed.
Report with checkpoints
Section titled “Report with checkpoints”The client must send TrafficCheckpoint messages regularly: the bytes moved since the session started, the current time, and a signature with the checkpoint key. That key is the Ed25519 key whose public half the client named in checkpoint_public_key when it opened the session.
Checkpoints do two things:
- they show the session is alive. A Control Plane closes a session it has not heard from for about five minutes;
- they get the node paid. The time a node has served is built from checkpoints.
Send one roughly every 30 seconds. More often gains nothing; much less often and the session risks being taken for abandoned.
The Control Plane answers each accepted checkpoint with a NonceUpdate, and the next checkpoint must carry the next_nonce it gave you. That is how the Control Plane tells a live session from a recording played back. If you reconnect and your nonce is stale, just send the checkpoint with the last nonce you have: with a correct signature that is not an error, and the Control Plane replies with a fresh NonceUpdate.
Where to send them. Usually to the Control Plane that issued the session. When SessionCredentials carries a non-zero report_to_star_id, though, the node belongs to another operator and you report to that one: only it holds the nonce and the node’s connection, so only it can pay the node. Look its address up in CPEndpointRegistry and open a separate stream there for this session’s checkpoints. No sign-in is needed; it recognises the session from its own record.
When a checkpoint is refused, the Control Plane says why:
| What happened | The answer |
|---|---|
| the session belongs to another Control Plane | ERROR_CODE_SESSION_NOT_FOUND, “this Control Plane cannot attribute that session” |
| this Control Plane has no nonce for the session — you are probably reporting to the wrong one | ERROR_CODE_SESSION_NOT_FOUND, “holds no nonce for that session” |
| the nonce does not match, and the signature is not this session’s either | ERROR_CODE_AUTH_FAILED |
What gets credited. Time is what is paid for, but the bytes decide whether it counts:
- time is held back until 64 KiB has moved since the last credited checkpoint, and then counts in full, quiet minutes included;
- if 10 minutes pass from the last credited checkpoint without reaching 64 KiB, the held-back time is dropped, however many checkpoints were sent meanwhile;
- a checkpoint that works out to more than 10 Gbps is refused.
So a client must report real counters. All three numbers are network parameters; see limits.
Renew the session
Section titled “Renew the session”SessionCredentials.expires_at is a real deadline. When it passes, the node deletes the account and the tunnel stops working. To keep the session going, the client must send a RenewSessionRequest before then.
The request is signed with the checkpoint key:
keccak256("singula-renew-v1" ‖ session_uuid ‖ 0x00 ‖ request_id ‖ timestamp)timestamp is eight bytes big-endian and must be within five minutes of the Control Plane’s clock. The signature proves the request comes from whoever holds the session, so it can arrive over any connection, even a short one opened just for the renewal. Send it to the Control Plane the session reports to.
What else to include:
| Session | Add to the request |
|---|---|
| wallet | nothing. The Control Plane checks the Orbit is still active |
| anonymous, same window | nothing. The session keeps its permission while it runs |
| anonymous, a new window has begun | a permission for the new window |
The answer is a RenewSessionResult with the new expires_at. Start renewing with time to spare and retry if it fails: a session that is not renewed ends at its deadline. Wanderer, for example, starts ten minutes ahead and retries every minute.
Close the session
Section titled “Close the session”When the session is no longer needed, send SessionEnd with the final counters. The node then frees the slot at once, and an anonymous session gives its permission back, so it can be taken again in under a minute.
A wallet session is closed from the stream that signed in. An anonymous one is closed over any connection with a signature by the checkpoint key:
keccak256("singula-end-v1" ‖ session_uuid ‖ final_bytes_uploaded ‖ final_bytes_downloaded ‖ timestamp)The counters and the time are eight bytes big-endian each. The Control Plane answers a signed SessionEnd with SessionEndResult.
If a client simply disappears, the Control Plane closes the session once checkpoints stop. Until then its permission stays taken.
Gas paid for you
Section titled “Gas paid for you”Four operations are sent to the chain by the Control Plane at its own expense, on your signature:
- buying an Orbit —
MintRequest; - activating it —
ActivationRequest; - publishing a commitment for anonymous access —
RegisterCommitmentRequest; - on a test network, topping a wallet up with test USDC to at most 100 —
FaucetRequest.
Before sending, the Control Plane dry-runs the transaction and does not send one that would revert. If your request comes back refused with a revert message, nothing reached the chain and nobody paid gas.
Where to start
Section titled “Where to start”Download client.proto and admin.proto, generate a client for your language, and follow build a client. Wanderer is built on this same API and uses nothing else.
