Skip to content

The on-chain layer

The chain holds four things: who is who, who paid, who served how long, and who is owed for it. The network runs on Arbitrum.

Anything an owner can change can be read from the chain, and where that is so, this page names the call that returns the live value.

flowchart TB
    sp["SingulaProtocol<br/><i>the directory</i>"]
    sr["StarRegistry<br/><i>who is who + the epoch clock</i>"]
    cpe["CPEndpointRegistry<br/><i>CP addresses</i>"]
    snft["SubscriptionNFT<br/><i>payment</i>"]
    sub["SubscriberRegistry<br/><i>the anonymity set</i>"]
    ea["EpochAttestations<br/><i>seconds served</i>"]
    rd["RewardDistributor<br/><i>payouts in USDC</i>"]
    clk["ServiceClock<br/><i>pause and service time</i>"]
    lm["LiquidityManager"]
    ut["UsdcTreasury<br/><i>the DAO's USDC</i>"]

    sp -.->|"names every address below"| sr
    sr -.->|role, signer, epoch| cpe
    sr -.->|role, signer, epoch| ea
    sr -.->|role, owner| rd
    snft -->|"is the Orbit active"| sub
    snft -->|"node and CP pools"| rd
    snft -->|"10% to liquidity"| lm
    snft -->|"10% to the DAO"| ut
    ut -.->|"tops up the pools"| rd
    ea -->|"seconds per epoch"| rd
    clk -.->|pause| snft
    clk -.->|pause| ea
    clk -.->|service time| rd
0xC7aA88375cE5B1d43768EA0545069CB294Fc2992

The one address a client should build in. Everything else is read from it.

snapshot() returns in one call: the chain id, the current epoch, the epoch parameters, and the addresses of StarRegistry, SubscriberRegistry, VoucherKeyRegistry, CPEndpointRegistry, SubscriptionNFT, EpochAttestations, RewardDistributor, EchoToken, the USDC token and GasStation.

Contracts not in that list, ServiceClock among them, are found by name with contractFor(bytes32), the name written as plain ASCII left-aligned in a bytes32; contractNames() lists them. Network-wide numbers sit beside them in param(bytes32), an open map, so a new value can be published without changing the address everyone has built in. The network’s limits live there; see limits.

The clock is passed through rather than stored: currentEpoch(), epochZeroStart() and epochDuration() read straight from StarRegistry, which fixes them at deployment. If StarRegistry is not set, they revert with ClockUnavailable instead of returning a believable zero.

Only its owner, ProtocolGovernor, can write, and every write emits ContractSet or ParamSet with both the old and the new value, so changes are easy to watch.

Contracts have been redeployed during the alpha and will be again. This address is what keeps third-party clients working through that. Take addresses from here, and take your RPC endpoint from your own build or your user, never from a Control Plane.

Today the governor’s owner is the deployer key, as is the owner of every contract below.

ProtocolGovernor owns SingulaProtocol, and every write to the directory goes through it. Contract addresses pass through from the governor’s owner. A parameter can be set only within its bound, which bounds(key) returns: a range, a largest step of 20% and at least a week between changes. The range is the one Control Planes accept, so a published value is never one they ignore.

A parameter with a rule (rules(key)) is set by proposals, and the owner can no longer set it directly. The rule says how it is decided and by whom:

ParametersDecided by
minCheckpointBytes, maxCheckpointGap, maxBytesPerSecPerSessionStars
maxSessionsPerSub, accessQuotaPerEpoch, accessBudgetPerSlot, permissionsPerWindow, sessionLifetimeStars and subscribers

There are two chambers, and both vote from ordinary wallets.

  • Stars. A Star’s owner votes for it. A Star weighs the seconds EpochAttestations credited to it over 30 epochs, node and Control Plane seconds together, and a Control Plane’s seconds count twice. It must have worked in at least 7 of those epochs, or in a quarter of the protocol’s age while that is fewer.
  • Subscribers. The wallet that holds the Orbits votes, while one of them is active. It weighs the slot-time its Orbits served over 180 epochs. Each slot counts only from when it was there: a slot added later does not count for the time before it, and time paid ahead counts only once it has passed.

Both windows end at the last epoch whose seconds are final when the proposal opens, so every vote in a proposal is measured against the same figures. No voter weighs more than 10% of their chamber, or than an average participant if that is more: the average Star, or one slot held through the whole window.

  1. Opening. A Star that weighs at least as much as the average Star can open one, and so can a subscriber whose Orbits served at least one slot for 90 days (a quarter of the protocol’s age while it is younger than a year). The proposal names the parameter, the value, a title and a text. One proposal at a time per parameter, and only in a chamber its rule names. A creator whose proposal was rejected waits 7 epochs before opening another.
  2. Voting, 3 epochs. For, against or abstain, each vote with an optional reason. A vote can be changed until voting ends.
  3. Counting. When an epoch is finalized, RewardDistributor calls the governor, which counts every vote that has ended. A proposal passes if, in every chamber its rule names, the votes weigh at least 20% of the chamber and those for outweigh those against.
  4. Delay, 3 days. During the test stage the guardian (guardian()) can veto a passed proposal before it is done. It cannot set a value, and renounceGuardian() gives the right up for good.
  5. Running. On later epoch closes the parameter moves toward the value, one step of its bound at a time, until it gets there.

Anyone can also call settle() to count and run without waiting for a finalization. A failure in the governor never stops an epoch from being finalized.

For numbers such as prices a rule can be Median: voters each name a value within the bound instead of voting for or against, and the result is the weighted median, with each chamber’s votes scaled to an equal share.

Subscribers buy without ETH, and a Star’s owner is meant to keep its wallet cold, so nobody needs ETH to take part. The wallet signs the action, a proposal, a vote or a reply, as EIP-712 typed data Act(wallet, data, nonce, deadline) in the governor’s domain, and signs the GasStation meta-transaction around it. A Control Plane forwards both, the governor checks the signature and runs the action as the wallet, and GasStation pays. A Control Plane first checks the governor would accept the action and refuses one it would not. One wallet makes at most 16 votes and replies in one proposal.

The title and text of a proposal, the reason given with a vote, and replies posted with argueAsStar or argueAsSubscriber are events. They stay on chain without taking contract storage, and any interface can read them from the chain’s logs. A reply names the argument it answers.

Views: proposal(id), tally(id), ballots(id), openProposals(), chamberTotals(epoch), starWeight(starId, epoch), subscriberWeight(wallet, epoch).

Every participant is a Star with an {owner, signer} pair and a role: None, Node, CP or Banned. Anyone can call register(signer) and gets a Node straight away, with no stake and no queue. During the test stage the founder sets the CP role by hand with forceSetRole; see becoming a Control Plane for how that changes.

This contract is also the protocol’s clock. EPOCH_ZERO_START and EPOCH_DURATION are fixed at deployment, and every other contract reads currentEpoch() from here. An epoch is 24 hours.

The keys, the roles and slashing are covered in Stars and roles.

The protocol’s pause and service time. While the protocol is paused, Orbits cannot be bought or activated, and EpochAttestations accepts no time served.

Service time is calendar time with every pause taken out: serviceTimeAt(t) = t − pausedBefore(t). A subscription’s term is a stretch of service time fixed at activation, so a pause rewrites nothing in a subscription: only its calendar dates move. calendarAt(s) turns service time into calendar time; for a moment still ahead it assumes a running pause ends now.

The owner can pause, until it calls unpause; so can the guardian, once and for at most 14 days (GUARDIAN_PAUSE), after which the pause ends by itself. The guardian can pause again after an unpause. paused(), pauseCount() and pauseAt(i) show the state and the history of pauses.

The public list of Control Plane hosts and ports, by Star id. This is where a client gets the list it chooses from.

It is a contract of its own because only Control Planes need a findable address. Keeping the field in StarRegistry would make every node pay storage for something it never uses.

setEndpoint can be called by the Star’s owner while the role is CP. Every read checks the role again, so getActiveEndpoints() drops a demoted Star at once. Anyone can free the storage with cleanupStale(starIds).

The same row holds the public key of the Control Plane’s front: setRealityKey(starId, key), owner only and while the role is CP; getRealityKey(starId) reads it.

Node addresses are nowhere on chain, neither here nor in StarRegistry.

The Orbit, as an ERC-721: 1 to 5 slots (slotsOf), 10 sUSDC a slot, 30 days from activation. slotsNow(holder) returns the slots of the Orbit running now. Before activation it transfers freely, up to ten unactivated per address; after activation it is bound to its holder.

The term is kept in ServiceClock’s service time (serviceStart(tokenId)), and activatedAt and expiresAt turn it into calendar dates, so a pause moves them by its length. An Orbit activated while another of the same holder’s is running queues behind it.

mintCost(slots) and addSlotsCost(tokenId, extra) return what a purchase costs now, with a discount when a promotion is running. A purchase reports the pools’ share and the time sold to RewardDistributor (notifySale); an activation and added slots report the stretch of service time they cover (notifyService).

The payment split is changed with setSplitBps and always adds up to 10 000 basis points. On chain today: 70% node pool, 10% UsdcTreasury, 10% liquidity, 5% CP pool, 5% founder, 0% buyback. Read it with getSplit().

mintWithPermit lets a wallet with no ETH buy one: an EIP-712 intent plus an ERC-20 permit, sent through GasStation. The wallet signs a price ceiling, maxCost, and pays the gas in USDC along with the price (GasStation.gasFee). Paying in ECHO is built in and switched off.

setPriceUSDC and setSplitBps ask RewardDistributor.checkRate first and revert if the node pool cannot pay for the time sold at the new rate.

The anonymity set. The holder of an active Orbit adds a commitment, which is a hash.

Usually this happens during activation: the activation names the commitments, one per slot, the holder signs them together, and SubscriptionNFT registers them in the same transaction. That way an Orbit is never active without its identity in the set. A holder who activated without one can register it separately.

To open a session, a client proves it knows the secret behind some commitment in the set and publishes a nullifier, so the same entitlement cannot be used twice. The proof never says which commitment.

Nothing is issued: the client builds its proof from a secret that never leaves its machine, so no Control Plane is present when the entitlement comes into being.

One commitment per slot of an Orbit (commitmentsOf(tokenId)). A commitment belongs to whoever first published it and stays reserved to them even after removal, so a returning subscriber can use the same one again.

removeExpired(tokenId) is open to anyone: expiry can be checked on chain, so anyone may remove a commitment whose Orbit has expired. Removal zeroes the slot instead of closing the gap, so positions never shift and commitmentsSlice returns zeros for removed members. A client rebuilding the set must keep those zeros as leaves.

circuitVersion() names the proving scheme, currently semaphore/4.14.3/16. Client and Control Plane must agree on it.

Nullifiers are not on chain. Each Control Plane keeps its own, scoped by its Star id.

Commitments to the keys Control Planes blind-sign permissions with, one per issuer and window.

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

keyHash is keccak256(DER ‖ denominationGB), with the count as four big-endian bytes; computeKeyHash does it for you. denominationGB carries how many permissions an Orbit has in that window.

setEpochKey can be called by the Star’s owner or its signer, while the role is CP, only for an epoch later than the current one, and only once per issuer and epoch. A slashed signer is refused. Committing ahead is what lets a client check it got the same key as everyone else.

Where time served is recorded.

struct PushItem {
uint64 node; // the node's Star
uint64 cp; // the Control Plane it was registered with
uint64 fromTs; // start of the interval
uint64 delta; // length in seconds
bytes32 session; // which session, as a hash
bytes32 permission; // which permission paid for it
bytes sig; // the node's own signature
}

push can be called by the signer of a Star with the CP role that is not slashed. The epoch comes from StarRegistry.currentEpoch(); the caller does not pass one.

Seconds are credited to the node named in each item. The caller’s own CP seconds grow only by items whose cp is the caller: the node signs which Control Plane it was registered with. So a Control Plane that pushes another’s intervals — to land the work of a peer that went down, so its nodes still get paid — credits those nodes and takes no CP share for it.

Time older than the start of the previous epoch is not credited: an item is trimmed to it, and an item wholly older credits nothing. So one epoch after an epoch closes, everything that could be credited for it has been. While ServiceClock is paused, push reverts with Paused.

Each item must have a non-zero delta and session, must not end in the future, and must carry an ECDSA signature that recovers to the node’s registered signer. The signed digest names the node, the Control Plane, the session, the permission, the interval, the epoch, this contract and this chain, so a signature cannot be reused anywhere else. An item naming an inactive node is skipped rather than failing the batch.

Two cursors that only move forward decide how much of an item is credited. The item is trimmed to the time after both, and an item that lies entirely before them credits nothing; the batch goes through either way:

  • sessionLastCoveredTs[session] — the same stretch of one session is never credited twice. It is keyed by session, not node, because a node serving several clients at once is normal.
  • permissionLastCoveredTs[permission] — one permission never covers two overlapping stretches. A permission holds one session at a time across the federation, so this keeps the time that can be claimed in line with what was bought.

An item with no permission skips that second check: the wallet tier has no permissions and is limited by its concurrent-session ceiling instead. The Pushed event reports how many such items a batch held, next to the number accepted.

The pools and the payouts, in USDC. How the economics works is in economics.

  • Nodes. A fixed rate, nodeRate(), for every second credited. finalizeEpoch(epoch) closes the epoch and any still open before it, fixes its rate (epochRate) and moves the count of paid time forward. Anyone may call it; claimNode finalizes by itself when it needs to.
  • Control Planes. Seats with a fixed pay per epoch: seatManager() calls openSeat, extendSeat and endSeat, and a seat’s whole term is reserved when it opens. Anyone may call settleSeat 7 epochs after a term ends.
  • The books. nodeAssets, nodeLiabilities, nodeAccrued, freeNode, cpAssets, seatReserved, spendableCp.
  • Promotions. A campaign opens at an epoch’s finalization once the last one has ended. campaign(), currentDiscountBps(), discountCeilingBps(), subsidyFor(nominal).
  • Money in and out. Anyone may call fundReserve(nodeAmount, cpAmount); releaseSurplus is owner only and never more than the free surplus.

Anyone can call claimNode and claimCp; the payee comes from StarRegistry. claimCp also needs the CP role and an unslashed signer.

Sales and activations are accepted only from the subscription contracts the owner has named. The current one (subscriptionNFT()) sets the price and the split; replaced ones keep reporting activations of their Orbits until the owner removes them (removeSource). Replacing the current one passes the same check as a price change.

The contract sits behind a UUPS proxy. Its owner can upgrade the implementation and point setAttestations at a different ledger.

Holds ETH and pays gas for protocol operations, so participants do not need any. Whoever submits a relayed transaction gets the gas it used back in the same transaction, up to the ceiling set for the function.

Two ways in:

  • relay — a meta-transaction signed by a Control Plane’s signer: (target, data, nonce, deadline) together with the chain id and the contract’s address. The Star must be an active CP and not blocked.
  • relayClient — the same, signed by the wallet itself: buying, activating, adding slots, the test faucet.

What may be relayed is set per contract and function (cpPolicy, clientPolicy): a class and a gas ceiling. A purchase pays its own gas: SubscriptionNFT takes gasFee(op, slots) in USDC along with the price. A Control Plane’s duties, such as claiming payouts, run within a per-CP gas budget per epoch. Free operations — the test faucet — are limited by per-wallet and network-wide budgets per epoch; the guardian can suspend them (pauseFree) and block a CP (blockCp), and only the owner lifts either.

gasFee works out the operation’s gas from its profile, the gas price on Arbitrum and L1, the ETH price from Chainlink and a margin, within a minimum and a maximum. When the contract’s ETH falls below a threshold, it swaps some of its USDC for ETH itself, at no less than Chainlink’s price minus a tolerance.

A Control Plane sends its own on-chain duties — time served, permission keys, its address, its signer — itself: those contracts check the sender.

Liquidity owned by the protocol. It receives the liquidity share of every payment, 10% today. Anyone can call addLiquidity(): half the USDC is swapped for ECHO, both halves go into the pool, and the LP position stays in the contract.

A single flag for the economic stage the network is in, which other contracts read instead of each keeping their own idea of it. advance() only moves forward; there is no setter and no way back.

A list of blocked domains. Control Planes read it and pass it to nodes, which send matching traffic nowhere.

EchoToken — 100M supply: 50% to RewardDistributor, 20% to DAOTreasury, 15% to the team with a 12-month cliff and 24-month vesting, 13% for marketing, 2% to the deployer for initial liquidity.

GovernorEcho — a one-day delay, a five-day vote, a 100 000 ECHO threshold to propose and a 4% quorum. The owner of an active Star with the CP role may propose.

DAOTreasury holds the DAO’s share, capped at 25% of circulating supply. Anything above the cap goes to the ECHO reward pool, and anyone can trigger that check.

UsdcTreasury holds the DAO’s USDC: the DAO share of the split goes there, and anyone can add to it with a plain transfer. Money leaves only by its owner’s decision, in two ways: fundRewardReserve(node, cp) adds it to the RewardDistributor pools, for example to pay new Control Planes’ seats in advance, and withdraw(to, amount, memo) pays anyone, with the purpose kept in the event.

Payouts are in USDC at this stage. The buyback share of the split is zero and buying with ECHO is switched off. ECHO for buying Orbits and paying Stars comes at a later stage; see economics.

Today the deployer key owns every contract: roles, promotions and bans, the directory, the payment split and the blocklist. Control moves to governance as it is handed over.

The owner’s power over prices and budgets is limited by the contracts themselves:

  • SubscriptionNFT moves the price by at most 20% at a time, at most once in 30 days, and never outside a tenth to ten times the price it was deployed with;
  • each share of the payment split moves by at most 5 percentage points at a time, at most once in 30 days;
  • ProtocolGovernor moves each network parameter by at most 20% at a time, at most once a week, and only within the range Control Planes accept, and a parameter with a rule only by a passed proposal;
  • GasStation’s gas budgets, margin and fee limits move by at most half at a time (or a small fixed step, if that is larger), at most once a week;
  • in RewardDistributor a CP seat’s pay moves by at most 20% at a time and never outside a tenth to ten times where it started, and the promotion settings move in small steps within their ranges, all at most once in 30 days.

Each contract exposes its limits as bounds(key), and a change outside them reverts.

  • Node addresses and ports. Only the Control Plane has them, and only while the node is connected.
  • Who a session belongs to. An interval names its session only as keccak256 of the session UUID, and its permission only as a digest of a blind-signed token. Neither leads back to a wallet or a commitment.
  • Nullifiers. Each Control Plane keeps its own.
  • Any link between a subscriber and traffic. SubscriptionNFT knows who paid, EpochAttestations knows who served, and no field joins the two.
  • Traffic volume. Byte counts travel in checkpoints so the Control Plane can refuse to credit an idle stretch, and they stop there. A recorded interval carries time only.