Build a client
A client does seven things: pin one address, find a Control Plane, prove it is entitled, get a session, raise the tunnel, report time, and renew the session before it runs out. This chapter walks through them in order and says what each step requires.
Wanderer, our own client, uses exactly this API. There are no privileged calls.
Step 0 — pin one address
Section titled “Step 0 — pin one address”Your build needs one constant:
SingulaProtocol 0xC7aA88375cE5B1d43768EA0545069CB294Fc2992Call snapshot() on it at start. You get the chain id, the current epoch, the epoch parameters, and the address of every contract the rest of this guide mentions.
Take no address from a Control Plane, and no RPC endpoint either. A Control Plane supplying both could answer every question you put to “the chain”, and every check below would pass against whatever it made up. The RPC endpoint comes from your build or from your user.
Step 1 — find a Control Plane
Section titled “Step 1 — find a Control Plane”CPEndpointRegistry.getActiveEndpoints() returns the live list, no signature needed. Call AdminService.GetHealth on each entry, time the answers, and sort.
Pick one yourself, and switch when it stops answering. Treat port == 0 as “the protocol’s default”.
Through a front
Section titled “Through a front”When a network blocks a Control Plane’s endpoint, reach it through its front instead. Every Control Plane runs one on port 443 of the host it publishes, with settings fixed by the protocol:
| Setting | Value |
|---|---|
| Protocol | VLESS over REALITY, raw TCP, flow xtls-rprx-vision |
| User id | 5c1e0d2b-4a01-4f3a-8e6d-77616e646572 |
| Server name | account.adobe.com |
| Short id | 5367756c61f30e01 |
| Public key | CPEndpointRegistry.getRealityKey(starId); Xray takes the 32 bytes as base64url |
Whatever destination the client names, the front sends the connection to the Control Plane’s own gRPC and nowhere else. So a local entry that tunnels a TCP connection through the front gives you an ordinary gRPC connection to that Control Plane — HTTP/2 without TLS, since REALITY has already authenticated it. A Control Plane that has not published a key has no front.
Wanderer times both ways to every Control Plane at start and uses the front wherever it answers.
Step 2 — prove you are entitled
Section titled “Step 2 — prove you are entitled”ClientService is a single bidirectional stream:
rpc Connect(stream ClientMessage) returns (stream ControlPlaneMessage);With a wallet. Send ClientAuth first. Its signature is an EIP-191 personal_sign of this text, with wallet_address, nonce and timestamp carrying the values you put into it:
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 daysThe Star id must be that of the Control Plane you are connected to; a signature naming another is refused. Put public_key, your ephemeral x25519 key, in the same message. An AuthResult comes back and the stream stays open. The signature is good for a week, so keep it for reconnects.
Without a wallet. Skip this step and put an AccessProof and a permission straight into the SessionRequest. What the proof says is in anonymous access; how to build it is in building a proof.
Step 3 — ask for a session
Section titled “Step 3 — ask for a session”Send a SessionRequest:
session_public_key— a fresh x25519 key for this session;preferred_country— an ISO code, or empty;chained—trueif you will reach the node through another proxy, so credentials come without XTLS Vision;checkpoint_public_key— the ed25519 key that will sign this session’s reports. Required on the anonymous path;permission— one of your Orbit’s permissions for this window. Required on the anonymous path: it limits how many sessions the Orbit holds at once across the network. Take permissions at start, not when a session is already needed.
SessionCredentials comes back.
Choosing the node yourself
Section titled “Choosing the node yourself”By default the Control Plane picks a node for you, by load and responsiveness. You can pick instead.
ListNodesRequest returns the nodes this Control Plane is serving right now:
| Field | Meaning |
|---|---|
star_id | On-chain Star id — look the owner up in StarRegistry |
country | As the node reports it |
latency_ms | Round trip from the Control Plane to the node |
load | 0.0 to 1.0 |
available | Whether it is taking sessions |
Put the id you chose in SessionRequest.node_star_id. A named node is served or refused, never swapped for another, so your choice cannot be quietly overridden. After a drop, name another node the same way if you want a different one.
How many nodes you can move between. On the anonymous path, permissions bound it: one permission opens one session. On the wallet path, different nodes are counted: in a 4-hour window, one wallet’s sessions on one Control Plane can land on at most three nodes per slot of the Orbit. Reconnecting to a node you have already used does not count.
About the latency figure. It is the Control Plane’s round trip to the node, not yours. It is still useful: you chose this Control Plane by timing it yourself, so add your own round trip to it and you have the best estimate of the whole path that anyone can make. The Control Plane cannot do better, since it does not know where you are.
What this is for. Choosing yourself keeps the path out of the Control Plane’s hands: one that picked every node could steer its clients onto nodes it works with. And two examples. A latency-sensitive client adds the two numbers and takes the lowest. A client building a chain reads StarRegistry.getOwner(starId) for each candidate and keeps its hops on different operators — worth doing, because two hops through one operator are two hops one party can see both ends of.
Wanderer does the second by default. Nothing enforces it: the route is yours to build, and the network’s job is to give you the facts to build it well.
Step 4 — open the credentials
Section titled “Step 4 — open the credentials”The credentials are encrypted by the node, to your key. The Control Plane relays them and cannot read them. To open them:
- Check who sent them. Verify
node_signer_signature: it is made by the node’s on-chain signer key overkeccak256("singula-node-session-v1" ‖ session_uuid ‖ your session_public_key ‖ the node's session_public_key), with the EIP-191 prefix. Recover the address and compare it withStarRegistry.getSigner(node_id). Refuse anything else: this is what ties the credentials to the Star you asked for, whoever relayed them. shared = X25519(your_session_private_key, session_public_key)key = HKDF-SHA256(secret = shared, salt = nil, info = "singula-session-v1", length = 32)- Decrypt
encrypted_credentialswith AES-256-GCM, using that key and the 12-bytenonce. Inside is aVlessCredentials.
From it you need uuid, address, endpoint_port, flow, reality_public_key, one entry from sni_pool (or server_name with reality_short_id when the pool is empty), fingerprint, transport_path, and covers if you want to switch covers later.
Pick a random sni_pool entry for each connection and rotate every rotation_period_sec.
Step 5 — raise the tunnel
Section titled “Step 5 — raise the tunnel”The tunnel is VLESS over REALITY. The node serves each cover two ways on the same port, and both lead to the same session:
- raw TCP with
flow— XTLS-Vision; - xHTTP at
transport_path, with no flow. The node matches the path exactly, so send it as given.
Each entry in covers has its own xhttp_path; an empty path means that cover is served over raw TCP only. Any engine that speaks these will do; for xHTTP you need one that supports it, such as Xray.
If raw TCP is blocked, switch to xHTTP or another cover on the same session — no new session is needed. From here your traffic goes straight to the node, and the Control Plane is not on the path.
Step 6 — report time
Section titled “Step 6 — report time”The client must send a TrafficCheckpoint regularly; roughly every 30 seconds works well. Checkpoints show the session is alive — a Control Plane closes a session it has not heard from for about five minutes — and they are what the node gets paid for.
Each checkpoint signs this digest:
digest = keccak256( "singula-cp-v1" || session_uuid || uint64_be(bytes_uploaded) || uint64_be(bytes_downloaded) || int64_be(timestamp) || nonce)Sign it with the wallet’s secp256k1 key in the wallet tier, or with the session’s ed25519 key in the anonymous one.
Check where to report first. SessionCredentials.report_to_star_id is usually zero, which means the Control Plane you asked. When it names a Star id, the node serving you belongs to another operator, and you report there: that Control Plane issued your nonce and holds the node’s connection, so only it can turn your checkpoints into payment for the node. Look its address up in CPEndpointRegistry yourself, open a second Connect stream to it, and send this session’s checkpoints there. No ClientAuth is needed; it recognises the session from its own record.
Report to the wrong one and you get ERROR_CODE_SESSION_NOT_FOUND with “holds no nonce for that session”. The tunnel keeps working, but the node earns nothing for it.
The nonce chain. The first checkpoint uses challenge_nonce from SessionCredentials. After each accepted checkpoint the Control Plane sends a NonceUpdate, and the next checkpoint must use its next_nonce. When a session reports elsewhere, its NonceUpdate arrives on that stream, so keep a nonce per session, not per connection.
If you lose step — after a reconnect, or a missed NonceUpdate — send the checkpoint anyway with the last nonce you have. A stale nonce under a correct signature gets a fresh NonceUpdate back instead of an error, and the chain carries on. A checkpoint that is really refused comes back as an Error naming the reason; log it rather than retrying blindly.
Report real counters. They do not change the payout — time is what is paid for — but they decide whether time counts. They are totals since the session started, not per interval. Time is held back until 64 KiB has moved since the last credited checkpoint and is then credited in full, quiet stretches included. If 10 minutes pass from the last credited checkpoint without reaching that, whatever was held back is dropped, however often you sent checkpoints in between.
Step 7 — renew before it runs out
Section titled “Step 7 — renew before it runs out”SessionCredentials.expires_at is a real deadline. Sessions are issued for a limited time, four hours by default, and when it passes the node deletes the credentials and the tunnel stops. A client that ignores this works perfectly in testing and drops its users in the middle of the afternoon.
Send a RenewSessionRequest before that moment, and retry until one succeeds; Wanderer starts ten minutes ahead and retries every minute. The answer is a RenewSessionResult, and on success its expires_at is the new deadline. Once the deadline has passed there is nothing left to renew, and the only way on is a new session.
Send it where the session reports. If report_to_star_id names another Control Plane, only that one holds the node’s connection and can extend the session on the node. Sign the request, because the stream you report on there did not open the session.
Proving the session is yours. Sign with the session’s checkpoint key, the ed25519 key you named in checkpoint_public_key:
digest = keccak256( "singula-renew-v1" || session_uuid || 0x00 || request_id || int64_be(timestamp))Put the signature in signature and the Unix time you signed at in timestamp. A timestamp more than five minutes from the Control Plane’s clock is refused. With a signature the request works over any stream. Without one it is accepted only on the stream that opened the session, or, for a wallet session, on a stream signed in as that wallet.
What else to include depends on how the session was opened:
| Session | The renewal carries |
|---|---|
| Wallet | Nothing more. The Control Plane checks the Orbit on chain again and refuses if it has lapsed |
| Anonymous, same window | Nothing more. The session keeps its permission while it runs |
| Anonymous, a new window has begun | permission — one of the Orbit’s permissions for the new window |
The window boundary is where a lapsed Orbit on the anonymous path stops: a permission for the new window needs a fresh membership proof, and a removed commitment cannot make one. Take that permission early in the window rather than at renewal time — a Control Plane issues permissions only for the current window, and the renewal that needs one may come hours later.
When you are done, close the session with SessionEnd; see closing a session.
What next
Section titled “What next”Multi-hop is addition: take a session from one Control Plane, a session from another, and join them yourself. The network neither knows nor needs to.
On the anonymous path, open every hop after the first with chained and a named node, at the Control Plane that holds that node: such a session is never handed to another operator. Ask for it through the hops already up, so its Control Plane sees the previous hop rather than you.
Keep the limits in mind — what they are and where they come from.
