Ончейн-слой
В блокчейне лежат четыре вещи: кто есть кто, кто заплатил, кто сколько отработал и кому сколько причитается. Сеть работает в Arbitrum.
Всё, что может поменять владелец, можно прочитать из блокчейна, и там, где это так, на этой странице указан вызов, который возвращает текущее значение.
flowchart TB
sp["SingulaProtocol<br/><i>директория</i>"]
sr["StarRegistry<br/><i>кто есть кто + часы эпох</i>"]
cpe["CPEndpointRegistry<br/><i>адреса CP</i>"]
snft["SubscriptionNFT<br/><i>оплата</i>"]
sub["SubscriberRegistry<br/><i>множество анонимности</i>"]
ea["EpochAttestations<br/><i>отработанные секунды</i>"]
rd["RewardDistributor<br/><i>выплаты в USDC</i>"]
clk["ServiceClock<br/><i>пауза и время обслуживания</i>"]
lm["LiquidityManager"]
ut["UsdcTreasury<br/><i>USDC DAO</i>"]
sp -.->|"называет все адреса ниже"| sr
sr -.->|роль, подписант, эпоха| cpe
sr -.->|роль, подписант, эпоха| ea
sr -.->|роль, владелец| rd
snft -->|"активен ли Orbit"| sub
snft -->|"пулы узлов и CP"| rd
snft -->|"10% в ликвидность"| lm
snft -->|"10% DAO"| ut
ut -.->|"пополняет пулы"| rd
ea -->|"секунды за эпоху"| rd
clk -.->|пауза| snft
clk -.->|пауза| ea
clk -.->|время обслуживания| rd
SingulaProtocol
Заголовок раздела «SingulaProtocol»0xC7aA88375cE5B1d43768EA0545069CB294Fc2992Единственный адрес, который стоит вшить в клиент. Всё остальное читается из него.
snapshot() за один вызов возвращает chain id, текущую эпоху, параметры эпох и адреса StarRegistry, SubscriberRegistry, VoucherKeyRegistry, CPEndpointRegistry, SubscriptionNFT, EpochAttestations, RewardDistributor, EchoToken, токена USDC и GasStation.
Контракты, которых нет в этом списке, например ServiceClock, находятся по имени через contractFor(bytes32) — имя записано обычным ASCII с выравниванием влево; contractNames() перечисляет их. Рядом лежат общесетевые числа — открытый словарь param(bytes32), так что новое значение можно опубликовать, не меняя адрес, который все вшили к себе. Там же живут ограничения сети, см. ограничения.
Часы не хранятся, а пробрасываются: currentEpoch(), epochZeroStart() и epochDuration() читают прямо из StarRegistry, где они заданы при развёртывании. Если StarRegistry не задан, они откатываются с ClockUnavailable, а не возвращают правдоподобный ноль.
Писать может только его владелец, ProtocolGovernor, и каждая запись порождает ContractSet или ParamSet со старым и новым значением, так что за изменениями легко следить.
Во время альфы контракты уже передеплоивались и будут ещё. Этот адрес и позволяет сторонним клиентам это пережить. Берите адреса отсюда, а RPC — из своей сборки или у своего пользователя, но никогда у Control Plane.
Сегодня владелец губернатора — ключ деплоера, как и у всех контрактов ниже.
ProtocolGovernor
Заголовок раздела «ProtocolGovernor»SingulaProtocol принадлежит ProtocolGovernor, и любая запись в директорию идёт через него. Адреса контрактов он передаёт от своего владельца без изменений. Параметр можно поставить только в его пределах, их возвращает bounds(key): диапазон, наибольший шаг 20% и не меньше недели между изменениями. Диапазон совпадает с тем, что принимают Control Plane, поэтому опубликованное значение они никогда не отбросят.
Параметр, у которого есть правило (rules(key)), меняется предложениями, и владелец больше не может поставить его напрямую. Правило говорит, как и кем он решается:
| Параметры | Кто решает |
|---|---|
minCheckpointBytes, maxCheckpointGap, maxBytesPerSecPerSession | звёзды |
maxSessionsPerSub, accessQuotaPerEpoch, accessBudgetPerSlot, permissionsPerWindow, sessionLifetime | звёзды и подписчики |
Кто голосует и с каким весом
Заголовок раздела «Кто голосует и с каким весом»Палат две, и обе голосуют с обычных кошельков.
- Звёзды. За звезду голосует её владелец. Вес звезды — секунды, которые
EpochAttestationsзасчитал ей за 30 эпох, узловые и секунды Control Plane вместе, причём секунды Control Plane считаются вдвойне. Звезда должна была работать хотя бы в 7 из этих эпох, а пока протокол молод — в четверти его возраста, если это меньше. - Подписчики. Голосует кошелёк, на котором лежат Orbit, пока хотя бы один из них действует. Его вес — слот-время, которое его Orbit отслужили за 180 эпох. Каждый слот считается только с момента, когда он появился: докупленный слот не весит за время до покупки, а оплаченное наперёд время засчитывается, только когда прошло.
Оба окна заканчиваются на последней эпохе, чьи секунды уже окончательны в момент открытия предложения, поэтому все голоса одного предложения считаются по одним и тем же цифрам. Ни один голосующий не весит больше 10% своей палаты или больше среднего участника, если это больше: средней звезды или одного слота, державшегося всё окно.
Предложение
Заголовок раздела «Предложение»- Открытие. Открыть предложение может звезда, которая весит не меньше средней, и подписчик, чьи Orbit отслужили хотя бы один слот за 90 дней (пока протоколу меньше года — за четверть его возраста). В предложении указаны параметр, значение, заголовок и текст. По одному параметру одновременно идёт одно предложение, и открыть его можно только в палате, которую называет правило. Автор отклонённого предложения ждёт 7 эпох, прежде чем открыть следующее.
- Голосование, 3 эпохи. За, против или воздержаться, к каждому голосу можно приложить обоснование. Пока голосование идёт, голос можно поменять.
- Подсчёт. Когда эпоха финализируется,
RewardDistributorвызывает губернатор, и тот подсчитывает все закончившиеся голосования. Предложение принято, если в каждой палате, которую называет правило, голоса весят хотя бы 20% палаты и голоса «за» перевешивают голоса «против». - Задержка, 3 дня. На стадии теста гарант (
guardian()) может наложить вето на принятое предложение, пока оно не исполнено. Поставить значение он не может, аrenounceGuardian()отказывается от этого права навсегда. - Исполнение. На следующих закрытиях эпох параметр движется к значению, по одному шагу своих пределов за раз, пока не дойдёт.
Подсчитать и исполнить, не дожидаясь финализации, может кто угодно вызовом settle(). Сбой в губернаторе никогда не мешает финализировать эпоху.
Для чисел вроде цен правило может быть Median: каждый голосующий называет значение в пределах вместо «за» или «против», а итогом становится взвешенная медиана, где голоса каждой палаты приведены к равной доле.
Без ETH
Заголовок раздела «Без ETH»Подписчики покупают без ETH, а кошелёк владельца звезды по задумке холодный, поэтому для участия ETH не нужен никому. Кошелёк подписывает действие — предложение, голос или реплику — как типизированные данные EIP-712 Act(wallet, data, nonce, deadline) в домене губернатора, и подписывает метатранзакцию GasStation вокруг него. Control Plane пересылает обе подписи, губернатор проверяет подпись и выполняет действие от имени кошелька, а платит GasStation. Control Plane сначала проверяет, примет ли губернатор действие, и отказывает, если нет. Один кошелёк делает в одном предложении не больше 16 голосов и реплик.
Обсуждение
Заголовок раздела «Обсуждение»Заголовок и текст предложения, обоснование голоса и реплики через argueAsStar или argueAsSubscriber — это события. Они остаются на чейне, не занимая хранилище контракта, и любой интерфейс читает их из логов блокчейна. Реплика указывает, на какую она отвечает.
Чтение: proposal(id), tally(id), ballots(id), openProposals(), chamberTotals(epoch), starWeight(starId, epoch), subscriberWeight(wallet, epoch).
StarRegistry
Заголовок раздела «StarRegistry»Каждый участник — звезда с парой {owner, signer} и ролью: None, Node, CP или Banned. register(signer) может вызвать кто угодно и сразу получает Node, без залога и очереди. На стадии теста роль CP выставляет основатель вручную через forceSetRole; как это изменится — в разделе как стать Control Plane.
Этот контракт — ещё и часы протокола. EPOCH_ZERO_START и EPOCH_DURATION заданы при развёртывании, а все остальные контракты берут currentEpoch() отсюда. Эпоха длится 24 часа.
Про ключи, роли и слэшинг — в главе звёзды и роли.
ServiceClock
Заголовок раздела «ServiceClock»Пауза протокола и время обслуживания. Пока протокол на паузе, нельзя купить и активировать Orbit, а EpochAttestations не принимает отработанное время.
Время обслуживания — календарное время за вычетом всех пауз: serviceTimeAt(t) = t − pausedBefore(t). Срок подписки — отрезок времени обслуживания, который фиксируется при активации, поэтому пауза ничего в подписках не переписывает: сдвигается только их календарная дата. calendarAt(s) переводит время обслуживания в календарное; для момента впереди оно считает, что идущая пауза кончится сейчас.
Поставить паузу может владелец — до своего unpause — или guardian, один раз и не дольше 14 дней (GUARDIAN_PAUSE), после чего пауза кончается сама. Снова пауза guardian доступна после unpause. paused(), pauseCount() и pauseAt(i) показывают состояние и историю пауз.
CPEndpointRegistry
Заголовок раздела «CPEndpointRegistry»Публичный список хостов и портов Control Plane по идентификатору звезды. Отсюда клиент берёт список, из которого выбирает.
Отдельный контракт нужен потому, что находимый адрес нужен только Control Plane. Если держать это поле в StarRegistry, за хранение платил бы каждый узел, которому оно ни к чему.
setEndpoint вызывает владелец звезды, пока у неё роль CP. Каждое чтение заново проверяет роль, поэтому getActiveEndpoints() сразу перестаёт отдавать пониженную звезду. Освободить место может кто угодно вызовом cleanupStale(starIds).
В той же записи лежит открытый ключ фронта Control Plane: setRealityKey(starId, key) вызывает владелец и только при роли CP, а прочитать ключ можно через getRealityKey(starId).
Адресов узлов в блокчейне нет нигде — ни здесь, ни в StarRegistry.
SubscriptionNFT
Заголовок раздела «SubscriptionNFT»Orbit в виде ERC-721: от 1 до 5 слотов (slotsOf), 10 sUSDC за слот, 30 дней с активации. slotsNow(holder) возвращает слоты Orbit, который идёт сейчас. До активации его можно передавать, неактивированных на адресе до десяти; после активации он привязан к владельцу.
Срок хранится во времени обслуживания ServiceClock (serviceStart(tokenId)), а activatedAt и expiresAt переводят его в календарные даты, поэтому пауза сдвигает их на свою длину. Orbit, активированный, пока идёт другой Orbit того же владельца, встаёт за ним в очередь.
Сколько покупка стоит сейчас, возвращают mintCost(slots) и addSlotsCost(tokenId, extra) — со скидкой, если идёт промо. Покупка сообщает RewardDistributor долю пулов и проданное время (notifySale), а активация и добавленные слоты — отрезок времени обслуживания, который они покрывают (notifyService).
Сплит оплаты меняется вызовом setSplitBps, и в сумме доли всегда дают 10 000 базисных пунктов. Сейчас: 70% в пул узлов, 10% в UsdcTreasury, 10% в ликвидность, 5% в пул CP, 5% основателю, 0% на выкуп. Прочитать — getSplit().
mintWithPermit позволяет купить Orbit кошельку без ETH: намерение EIP-712 плюс permit на ERC-20, отправленные через GasStation. Кошелёк подписывает потолок цены maxCost и вместе с ценой платит в USDC газ (GasStation.gasFee). Оплата в ECHO встроена и выключена.
setPriceUSDC и setSplitBps сначала спрашивают RewardDistributor.checkRate и откатываются, если пул узлов не может оплатить проданное время по новой ставке.
SubscriberRegistry
Заголовок раздела «SubscriberRegistry»Множество анонимности. Владелец активного Orbit добавляет в него коммитмент — хеш.
Обычно это происходит при активации: активация называет коммитменты, по одному на слот, владелец подписывает всё вместе, и SubscriptionNFT регистрирует их в той же транзакции. Так Orbit никогда не бывает активным без своих личностей в множестве. Тот, кто активировал без коммитментов, может зарегистрировать их отдельно.
Чтобы открыть сессию, клиент доказывает, что знает секрет за каким-то коммитментом из множества, и публикует нуллификатор, чтобы одним правом нельзя было воспользоваться дважды. Какой именно коммитмент его, доказательство не говорит.
Ничего не выдаётся: клиент сам строит доказательство из секрета, который не покидает его машину, поэтому ни один Control Plane не присутствует при том, как появляется право.
Один коммитмент на слот Orbit (commitmentsOf(tokenId)). Коммитмент принадлежит тому, кто опубликовал его первым, и остаётся за ним даже после удаления, так что вернувшийся подписчик может снова использовать тот же.
removeExpired(tokenId) может вызвать кто угодно: истечение проверяется по блокчейну, поэтому удалить коммитмент истёкшего Orbit разрешено всем. Удаление обнуляет ячейку, а не схлопывает список, так что позиции не сдвигаются, и commitmentsSlice возвращает нули на месте удалённых. Клиент, который пересобирает множество, должен сохранить эти нули как листья.
circuitVersion() называет схему доказательств, сейчас semaphore/4.14.3/16. Клиент и Control Plane должны в ней совпадать.
Нуллификаторов в блокчейне нет. Каждый Control Plane хранит свои, с областью по своему идентификатору звезды.
VoucherKeyRegistry
Заголовок раздела «VoucherKeyRegistry»Коммитменты к ключам, которыми Control Plane вслепую подписывают разрешения, — по одному на издателя и окно.
getEpochKey(issuerStarId, epoch) returns (bytes32 keyHash, uint32 denominationGB, uint64 setAt, bool issuerActive)keyHash — это keccak256(DER ‖ denominationGB), где число записано четырьмя байтами big-endian; посчитать поможет computeKeyHash. В denominationGB лежит число разрешений, которые есть у Orbit в этом окне.
setEpochKey может вызвать владелец звезды или её подписант, при роли CP, только на эпоху позже текущей и только один раз на издателя и эпоху. Подписанту со слэшингом отказывают. Именно запись заранее позволяет клиенту проверить, что ему дали тот же ключ, что и всем.
EpochAttestations
Заголовок раздела «EpochAttestations»Сюда записывается отработанное время.
struct PushItem { uint64 node; // звезда узла uint64 cp; // Control Plane, к которому он был подключён uint64 fromTs; // начало интервала uint64 delta; // длительность в секундах bytes32 session; // какая сессия, хешем bytes32 permission; // какое разрешение за неё заплатило bytes sig; // подпись самого узла}push может вызвать подписант звезды с ролью CP без слэшинга. Эпоха берётся из StarRegistry.currentEpoch(), вызывающий её не передаёт.
Секунды начисляются узлу, названному в записи. Секунды CP у вызывающего растут только на записи, где cp — он сам: узел подписывает, к какому Control Plane он был подключён. Поэтому Control Plane, который отправляет интервалы другого — чтобы работа упавшего соседа всё же дошла и его узлам заплатили, — начисляет узлам, а доли CP за это не получает.
Время старше начала предыдущей эпохи не засчитывается: запись обрезается до него, а запись целиком раньше не даёт ничего. Поэтому через эпоху после закрытия всё, что за эпоху можно было засчитать, уже засчитано. Пока ServiceClock на паузе, push откатывается с Paused.
У каждой записи должны быть ненулевые delta и session, она не должна заканчиваться в будущем, и подпись ECDSA должна восстанавливаться в зарегистрированного подписанта узла. Подписанный дайджест называет узел, Control Plane, сессию, разрешение, интервал, эпоху, этот контракт и эту сеть, так что подпись нельзя использовать где-то ещё. Запись с неактивным узлом пропускается, а не роняет всю пачку.
Сколько из записи засчитать, решают два курсора, которые движутся только вперёд. Запись обрезается до времени после обоих, а запись, которая целиком раньше них, не даёт ничего; пачка проходит в любом случае:
sessionLastCoveredTs[session]— один и тот же отрезок одной сессии не засчитывается дважды. Ключ — сессия, а не узел, потому что узел, обслуживающий несколько клиентов сразу, — это нормально.permissionLastCoveredTs[permission]— одно разрешение не покрывает два пересекающихся отрезка. Разрешение держит одну сессию за раз во всей федерации, так что это держит засчитываемое время в рамках купленного.
Запись без разрешения пропускает вторую проверку: на кошельковом уровне разрешений нет, его ограничивает предел одновременных сессий. Событие Pushed сообщает, сколько таких записей было в пачке, рядом с числом принятых.
RewardDistributor
Заголовок раздела «RewardDistributor»Пулы и выплаты в USDC. Как устроена экономика — в главе экономика.
- Узлы. Фиксированная ставка
nodeRate()за каждую засчитанную секунду.finalizeEpoch(epoch)закрывает эпоху и все незакрытые до неё, фиксирует её ставку (epochRate) и сдвигает счёт оплаченного времени. Вызвать может кто угодно;claimNodeфинализирует сам, если нужно. - Control Plane. Места с фиксированной оплатой за эпоху:
openSeat,extendSeatиendSeatвызываетseatManager(), оплата всего срока резервируется при открытии.settleSeatвызывает кто угодно через 7 эпох после конца срока. - Книги.
nodeAssets,nodeLiabilities,nodeAccrued,freeNode,cpAssets,seatReserved,spendableCp. - Промо. Кампания открывается при финализации эпохи, когда прошлая кончилась.
campaign(),currentDiscountBps(),discountCeilingBps(),subsidyFor(nominal). - Деньги извне и наружу.
fundReserve(nodeAmount, cpAmount)вызывает кто угодно;releaseSurplus— только владелец и не больше свободного излишка.
claimNode и claimCp может вызвать кто угодно, получатель берётся из StarRegistry. claimCp ещё требует роль CP и подписанта без слэшинга.
Продажи и активации принимаются только от контрактов подписки, которые назвал владелец. Текущий (subscriptionNFT()) задаёт цену и сплит; заменённые продолжают сообщать об активациях своих Orbit, пока владелец не уберёт их (removeSource). Замена текущего проходит ту же проверку, что и смена цены.
Контракт стоит за прокси UUPS. Владелец может обновить реализацию и указать setAttestations на другой реестр.
GasStation
Заголовок раздела «GasStation»Держит ETH и платит газ за операции протокола, чтобы участникам не нужно было его держать. Тот, кто отправил ретранслируемую транзакцию, получает потраченный газ обратно в той же транзакции, не больше потолка, заданного для функции.
Два входа:
relay— мета-транзакция, подписанная подписантом Control Plane:(target, data, nonce, deadline)вместе с chain id и адресом контракта. Звезда должна быть активным CP и не быть заблокированной.relayClient— то же, подписанное самим кошельком: покупка, активация, добавление слотов, тестовый кран.
Что можно ретранслировать, задаёт политика на пару «контракт, функция» (cpPolicy, clientPolicy): класс и потолок газа. Покупка оплачивает газ сама — SubscriptionNFT берёт gasFee(op, slots) в USDC вместе с ценой. Обязанности Control Plane вроде получения выплат идут в пределах бюджета газа на CP за эпоху. Бесплатные операции — тестовый кран — ограничены бюджетами на кошелёк и на всю сеть за эпоху; guardian может приостановить их (pauseFree) и заблокировать CP (blockCp), снимает это только владелец.
gasFee считает газ операции по её профилю, цену газа в Arbitrum и L1, цену ETH из Chainlink и наценку, в пределах минимума и максимума. Если ETH на контракте меньше порога, он сам меняет часть USDC на ETH, не дешевле цены Chainlink за вычетом допуска.
Свои ончейн-обязанности — отработанное время, ключи разрешений, адрес, подписант — Control Plane отправляет сам: эти контракты проверяют отправителя.
LiquidityManager
Заголовок раздела «LiquidityManager»Ликвидность, принадлежащая протоколу. Получает долю ликвидности с каждой оплаты, сегодня 10%. addLiquidity() может вызвать кто угодно: половина USDC меняется на ECHO, обе половины идут в пул, а позиция LP остаётся в контракте.
StageManager
Заголовок раздела «StageManager»Один флаг стадии, на которой находится экономика сети. Другие контракты читают его, а не держат каждый своё представление. advance() двигает стадию только вперёд; ни сеттера, ни отката нет.
DomainBlocklist
Заголовок раздела «DomainBlocklist»Список заблокированных доменов. Control Plane читает его и передаёт узлам, а те отправляют совпавший трафик в никуда.
ECHO, казна и управление
Заголовок раздела «ECHO, казна и управление»EchoToken — эмиссия 100M: 50% в RewardDistributor, 20% в DAOTreasury, 15% команде с годовым локапом и вестингом на 24 месяца, 13% на маркетинг, 2% деплоеру на начальную ликвидность.
GovernorEcho — задержка сутки, голосование пять дней, порог предложения 100 000 ECHO, кворум 4%. Предлагать может владелец активной звезды с ролью CP.
DAOTreasury хранит долю DAO, не больше 25% от обращающегося предложения. Всё сверх этого уходит в пул наград ECHO, и запустить эту проверку может кто угодно.
UsdcTreasury хранит USDC DAO: в неё идёт доля DAO из сплита, и пополнить её может кто угодно простым переводом. Деньги выходят только по решению владельца — двумя путями: fundRewardReserve(node, cp) добавляет их в пулы RewardDistributor, например чтобы заранее оплатить места новым Control Plane, а withdraw(to, amount, memo) платит кому угодно, и назначение остаётся в событии.
На этой стадии выплаты идут в USDC. Доля выкупа в сплите равна нулю, покупка за ECHO выключена. ECHO для покупки Orbit и выплат звёздам появится на следующей стадии; см. экономику.
Кто чем владеет
Заголовок раздела «Кто чем владеет»Сегодня всеми контрактами владеет ключ деплоера: роли, повышения и баны, директория, сплит оплаты и блоклист. Управление переходит к governance по мере того, как его передают.
Власть владельца над ценами и бюджетами ограничена в самих контрактах:
SubscriptionNFTменяет цену не больше чем на 20% за раз, не чаще раза в 30 дней и никогда не выходит за пределы от десятой части до десятикратной цены, с которой контракт развёрнут;- каждая доля сплита сдвигается не больше чем на 5 процентных пунктов за раз, не чаще раза в 30 дней;
ProtocolGovernorсдвигает каждый параметр сети не больше чем на 20% за раз, не чаще раза в неделю и только внутри диапазона, который принимают Control Plane, а параметр с правилом — только принятым предложением;- бюджеты газа, наценка и пределы платы за газ в
GasStationменяются не больше чем наполовину за раз (или на небольшой фиксированный шаг, если он больше) и не чаще раза в неделю; - в
RewardDistributorоплата места CP меняется не больше чем на 20% за раз и не выходит за пределы от десятой части до десятикратной начальной, а параметры промо двигаются небольшими шагами внутри своих диапазонов, всё не чаще раза в 30 дней.
Пределы каждый контракт отдаёт через bounds(key), а изменение за их границами откатывается.
Чего нет в блокчейне
Заголовок раздела «Чего нет в блокчейне»- Адресов и портов узлов. Они есть только у Control Plane и только пока узел подключён.
- Того, чья это сессия. Интервал называет сессию только хешем
keccak256её UUID, а разрешение — только дайджестом слепо подписанного токена. Ни то ни другое не ведёт к кошельку или коммитменту. - Нуллификаторов. Каждый Control Plane хранит свои.
- Связи между подписчиком и трафиком.
SubscriptionNFTзнает, кто заплатил,EpochAttestations— кто отработал, и ни одно поле их не соединяет. - Объёма трафика. Счётчики байт приходят в чекпоинтах, чтобы Control Plane мог не засчитать простой, и дальше не идут. В записанном интервале есть только время.
