Разработчикам

Связь устройств через контракты.

У каждого устройства Subrose есть свой контракт в TON — небольшая программа в блокчейне, которая хранит владельца, обслуживающую ноду и параметры устройства. Контракты в TON обмениваются сообщениями между собой, поэтому контракт одного устройства можно научить отправлять сообщение контракту другого: второе устройство меняет параметр, а обслуживающая нода доводит изменение до самого железа за секунды.

Готовых блоков для такой связи в контракте устройства нет. Логика пишется на Tact, языке смарт-контрактов TON, и приезжает на устройство обновлением кода на месте — адрес контракта при этом сохраняется. Ниже — весь путь: сообщение, приёмник, проверка отправителя, выкатка и проверка результата.

Сегодня контракт устройства принимает от других контрактов только пополнение баланса и снимок владельцев от своей группы, а остальные сообщения молча пропускает. Свой приёмник появляется в нём вместе с обновлением кода.

01 · Механизмы

Два способа связать два устройства.

Правило живёт в TON

Контракт первого устройства шлёт сообщение контракту второго, второй проверяет отправителя и меняет своё состояние. Правило исполняется само, без сервера посередине, и каждый его запуск остаётся в истории контракта. Отправка оплачивается газом (комиссией сети) с баланса контракта-отправителя.

Правило живёт в своём коде

Скрипт читает события первого устройства через HTTP API ноды и отдаёт команду второму. Реакция мгновенная, менять логику можно хоть каждый день, но работает она ровно столько, сколько работает сервер разработчика. Рецепты — на странице «События устройства».

Способы выбираются по цене ошибки. Правило, которое должно сработать даже когда чужой сервер выключен, стоит того, чтобы жить в TON. Остальное дешевле держать в скрипте.

02 · Требования

Что нужно до первой строчки кода.

Адреса обоих контрактов устройств — они видны в веб-консоли Subrose на карточке устройства.
Ключ, которым подписывается обновление кода: ключ самого устройства или владелец с полными правами в группе.
Баланс на контрактах: отправитель платит за отправку сообщения, получатель — за его обработку.
Собранный проект контрактов: yarn install и yarn build в services/ton.

Оба устройства при этом остаются обычными устройствами сети: своя нода, свой владелец, своя оплата. Совпадать у них ничего не обязано, и в одной группе они тоже быть не должны.

03 · Образец

Такой обмен уже работает внутри системы.

Группа устройств рассылает своим устройствам снимок списка владельцев, и контракт устройства этот снимок принимает. Приёмник занимает несколько строк, и в них видно всё, что нужно повторить в своём коде: проверка отправителя по адресу и защита от повторов по номеру версии.

Отправитель со своей стороны проверяет баланс до рассылки, чтобы сообщения ушли всем адресатам разом, и шлёт их без возврата при ошибке.

// Device.tact — приёмник снимка владельцев
receive(msg: SyncOwners) {
    throwUnless(self.ErrorNotGroup, sender() == self.group);
    if (msg.ownersVersion > self.syncedOwnersVersion) {
        self.groupOwners = msg.owners;
        self.syncedOwnersVersion = msg.ownersVersion;
    }
}

04 · Рецепт

Сигнал от датчика к исполнителю за четыре шага.

Сценарий для примера: камера у гаража заметила движение ночью и отправляет сигнал alarm. Второе устройство по этому сигналу встаёт на замок — флаг lock, при котором агент перестаёт исполнять команды. Все фрагменты ниже добавляются в контракт устройства services/ton/src/Device.tact.

Шаг 1 — описать сообщение

Сообщение — это структура с именем и полями. Поле at — время отправки по часам блокчейна: по нему получатель отличает свежий сигнал от повтора и от сообщения, которое пришло с опозданием. Собственный счётчик тут не годится: при переносе хранилища он начинается заново, и получатель, у которого записан прежний номер, замолчит навсегда. Время такой ловушки не создаёт.

// Сигнал соседнему устройству.
message DeviceSignal {
    name: String;       // что произошло: "alarm", "overheat", "gone"
    at: Int as uint32;  // время отправки по часам блокчейна
}

// Подписанные команды: отправить сигнал, править список, сменить адресата.
message SendSignal {
    name: String;
}

message SetPeer {
    peer: Address;
    allowed: Bool;
}

message SetSignalTarget {
    target: Address;
}

Шаг 2 — завести поля в хранилище

Всё состояние связи живёт в одном новом поле — структуре под ссылкой. Так сделано не для красоты: у контракта устройства почти исчерпан запас газа на распаковку хранилища в тот момент, когда подпись ещё не проверена. Несколько отдельных полей добавляют работы ровно в этом месте, и подписанные команды перестают приниматься — включая саму команду обновления кода, то есть контракт становится неуправляемым навсегда. Одна ссылка распаковывается лениво и в этот бюджет не лезет.

Поле дописывается в самый конец хранилища, после storageVersion, и заполняется в init. Как переносить хранилище на уже задеплоенном устройстве — в разделе «Обновление кода».

// Всё состояние связи — в одной структуре.
struct SignalState {
    peers: map<Address, Bool>;        // кто вправе слать сигналы
    peerCount: Int as uint8;
    lastSeen: map<Address, Int as uint32>; // время последнего принятого сигнала
    target: Address?;                 // кому это устройство шлёт свой сигнал
}

// В хранилище контракта — ОДНО новое поле, последним:
signal: SignalState?;

Шаг 3 — принять сигнал

Единственное доказательство отправителя в TON — адрес, с которого пришло сообщение: подделать его нельзя. Приёмник ставится выше общего receive(_: Slice) {} в конце контракта, иначе сообщение попадёт в него и будет проглочено.

// Константы объявляются ВНУТРИ contract Device, рядом с остальными:
const ErrorPeerNotAllowed: Int = 3101;
const ErrorNoSignalTarget: Int = 3102;
const ErrorTooManyPeers: Int = 3103;
const MAX_PEERS: Int = 8;
const SIGNAL_VALUE: Int = ton("0.02");

receive(msg: DeviceSignal) {
    let st = self.signal;
    // 1. Отправитель должен быть в списке разрешённых.
    throwUnless(self.ErrorPeerNotAllowed, st != null && st!!.peers.exists(sender()));
    let state = st!!;

    // 2. Повтор и отставший сигнал отбрасываем — но с ошибкой, а не молча:
    //    отказ виден в истории контракта, а тихий пропуск не виден никому.
    let seen = state.lastSeen.get(sender());
    throwUnless(self.ErrorPeerNotAllowed, seen == null || msg.at > seen!!);
    state.lastSeen.set(sender(), msg.at);
    self.signal = state;

    // 3. Реакция: встать на замок и записать событие.
    if (msg.name == "alarm") {
        self.lock = true;
        emit(DeviceEvent {
            contractAddress: myAddress(),
            name: "locked_by_peer",
        }.toCell());
    }
}

emit кладёт событие в историю контракта. Имя пишется своё, а структура берётся готовая — DeviceEvent: её нода уже умеет разбирать, поэтому событие появится в консоли и в ответе GET /events/Device/… без единой правки на стороне ноды.

Шаг 4 — отправить сигнал и настроить список

Отправку и список разрешённых отправителей заводят как обычные команды устройства — те же подписанные сообщения, что SetLock или SetEvents. Разбор команд лежит в dispatchSignedMessage, туда добавляются три ветки. Но отправка сигнала тратит деньги с баланса контракта, поэтому её нельзя пускать по общей проверке: команды устройства принимает и владелец с ограниченными правами, а он не должен уметь отправлять средства на выбранный им же адрес. Право на отправку закрывается тем же гейтом, что и замена кода — только ключ устройства или владелец с полными правами.

// В dispatchSignedMessage — рядом с остальными командами:
if (op == SendSignal.opcode()) {
    self.doSendSignal(SendSignal.fromCell(message).name);
    return;
}
if (op == SetPeer.opcode()) {
    let m = SetPeer.fromCell(message);
    self.doSetPeer(m.peer, m.allowed);
    return;
}
if (op == SetSignalTarget.opcode()) {
    self.doSetTarget(SetSignalTarget.fromCell(message).target);
    return;
}

// Денежные команды — по строгим правам. Проверка стоит в beforeSignedMessage:
// она выполняется уже ПОСЛЕ приёма сообщения, где газа достаточно.
override fun beforeSignedMessage(msg: SignedMessage) {
    let op = msg.message.beginParse().loadUint(32);
    if (op == Upgrade.opcode() ||
        op == SendSignal.opcode() ||
        op == SetSignalTarget.opcode()) {
        self.requireFullOrDeviceKey(msg.publicKey);
    }
}

fun doSendSignal(name: String) {
    let st = self.signal;
    throwUnless(self.ErrorNoSignalTarget, st != null && st!!.target != null);
    send(SendParameters {
        to: st!!.target!!,
        value: self.SIGNAL_VALUE,
        bounce: true,   // не дошло — деньги и остаток газа вернутся отправителю
        mode: SendPayFwdFeesSeparately,
        body: DeviceSignal { name, at: now() }.toCell(),
    });
}

fun emptyState(): SignalState {
    return SignalState { peers: emptyMap(), peerCount: 0, lastSeen: emptyMap(), target: null };
}

fun doSetPeer(peer: Address, allowed: Bool) {
    let state = self.signal == null ? self.emptyState() : self.signal!!;
    if (allowed) {
        if (!state.peers.exists(peer)) {
            throwUnless(self.ErrorTooManyPeers, state.peerCount < self.MAX_PEERS);
            state.peers.set(peer, true);
            state.peerCount = state.peerCount + 1;
        }
    } else if (state.peers.exists(peer)) {
        state.peers.del(peer);
        state.lastSeen.del(peer); // иначе вернувшийся сосед будет заглушён старой отметкой
        state.peerCount = state.peerCount - 1;
    }
    self.signal = state;
}

fun doSetTarget(target: Address) {
    let state = self.signal == null ? self.emptyState() : self.signal!!;
    state.target = target;
    self.signal = state;
}

Список разрешённых отправителей ограничен восемью адресами. Ограничение здесь не формальность: перебор карты стоит газа, а газ платит контракт устройства из своего баланса.

05 · Выкатка

Обновление кода на месте.

Новый код приезжает на уже работающее устройство командой Upgrade: контракт заменяет свою программу, сохраняя адрес, баланс и хранилище. Команда подписывается ключом устройства или владельцем с полными правами — правами уровня «ограниченный владелец» заменить код нельзя.

cd services/ton
yarn build                      # собрать контракты
yarn test                       # прогнать сценарий в песочнице

Перед выкаткой сценарий прогоняют в песочнице — этот шаг обязателен: тесты в services/ton/__tests__ поднимают оба контракта в памяти и проверяют, что чужой адрес получает отказ, а повторный сигнал ничего не меняет. Дальше Upgrade отправляется скриптом выкатки — примеры лежат рядом, в deploy/testnet.

Ловушка, на которой спотыкаются: TON не заполняет новые поля хранилища нулями. Контракт, задеплоенный без полей из шага 2, получив код с ними, упадёт на первом же сообщении и на любом запросе к нему. Поэтому хранилище переносят в два шага — сначала промежуточный код, который читает старую раскладку и собирает новую, потом уже настоящий новый код. Готовые примеры такого переноса лежат в services/ton/src/migrations. На новых устройствах, задеплоенных сразу с новым кодом, шаг лишний.

06 · Дальше по цепочке

Как изменение в контракте доходит до самого устройства.

Смена флага в контракте — это ещё не поведение железа. Дальше работает нода: она следит за новыми блоками TON и видит, что контракт её устройства изменился. После этого нода стучится к устройству с одним коротким сообщением — «состояние изменилось, проверь». Проходит это за пару блоков, то есть за секунды.

Само состояние в этом сообщении не передаётся. Агент идёт за ним в цепочку и проверяет полученное криптографически, поэтому подменить флаг по дороге нода не может. Прочитав свежий lock, агент перестаёт исполнять команды — сигнал соседа доехал до железа.

контракт камеры
   │  DeviceSignal{name:"alarm"}
   ▼
контракт второго устройства
   │  lock = true, событие "locked_by_peer"
   ▼
нода видит изменение в новом блоке
   │  «состояние изменилось, проверь»
   ▼
агент забирает состояние из цепочки,
проверяет его и встаёт на замок

07 · Проверка

Что смотреть после запуска.

События контракта читаются через HTTP API ноды. Тот же запрос показывает и штатные факты жизни устройства — регистрацию, смену ноды, — и события, добавленные своим кодом.

GET /events/Device/0:<адрес-контракта-устройства>

# Пример ответа:
[
  { "EventName": "DeviceEvent",
    "Data": { "contractAddress": "0:abc…def", "name": "locked_by_peer" },
    "Timestamp": 1751000123,
    "TxHash": "97f1…" }
]

Первое, что проверяют после обновления кода, — что устройство вообще принимает команды: любая подписанная команда, хоть SetDeviceName, должна пройти. Если она перестала приниматься, новый код съел запас газа на распаковку хранилища, и дальше пробовать нечего — сначала возвращают прежнюю раскладку.

Состояние устройства после сигнала показывает карточка в веб-консоли Subrose: замок, флаги и обслуживающая нода читаются прямо из контракта. Отказы видно там же — сигнал с адреса, которого нет в списке разрешённых, заканчивается ошибкой контракта, и запись о ней остаётся в истории.

08 · Правила

Пять вещей, на которых ломаются такие цепочки.

01Проверка отправителя обязательна в каждом приёмнике. Приёмник без sender() исполнит сигнал любого, кто заплатит за сообщение.
02Порядок сообщений в TON не гарантирован. Номер или версия в теле сигнала защищают от повтора и от обгона: без них старый сигнал вернёт устройство в отменённое состояние.
03Газ платит контракт. Отправка без остатка на балансе не уйдёт, а рассылка по списку стоит столько же, сколько адресатов в нём: баланс проверяется до отправки, чтобы часть сигналов не осталась недоставленной.
04Содержимое сообщения между контрактами видно всем. Секретам, токенам и личным данным там не место: в TON уходит сам факт, подробности остаются на устройстве.
05Замена кода — операция уровня денег: через контракт устройства идут платежи за обслуживание. Подписать её может ключ устройства или владелец с полными правами, и обновление стоит гонять в тестовой сети до боевой.
06Запас газа на распаковку хранилища у контракта устройства почти исчерпан, а тратится он до того, как подпись проверена. Каждое новое поле в хранилище приближает момент, когда перестанут приниматься все подписанные команды разом — вместе с командой обновления кода, то есть без права на ошибку. Поэтому новое состояние складывают в одно поле-ссылку и проверяют приём команд сразу после обновления.

09 · Вопросы

Перед тем как связывать устройства.

Меняется ли адрес контракта после обновления?Нет. Код заменяется на месте, адрес, баланс и хранилище остаются прежними — привязка устройства, настройки ноды и история платежей переживают обновление.
Может ли чужое устройство прислать команду?Прислать сообщение может кто угодно, а исполнить его — только если адрес отправителя есть в списке разрешённых. Проверка стоит первой строкой приёмника, и сообщение с чужого адреса заканчивается отказом.
Кто платит за такие сообщения?Контракты. Отправитель платит за отправку со своего баланса, получатель — за обработку со своего. Ни владелец, ни нода в этот момент ничего не подписывают.
Может ли устройство само запустить цепочку?Своими силами устройство отправляет в TON платёж за обслуживание. Свою цепочку запускает подписанная команда — из консоли, из скрипта или из своего сервиса, а поводом для неё служит событие, прочитанное через API ноды.
Нужно ли согласие производителя или ноды?Нет. Код контракта заменяет владелец устройства, а нода в этой цепочке доводит результат до железа и повлиять на решение контракта не может.
Что делать, если устройств больше двух?Список разрешённых отправителей и адресат сигнала — обычные поля контракта, они меняются подписанной командой. Для веерной рассылки в контракте держат список адресатов и проверяют баланс перед отправкой — ровно так это сделано в контракте группы устройств.

Начать

Первое звено цепочки.

Отправка события с устройства, чтение его через API и команда второму устройству — это тот же путь, только без правки контрактов. Рецепты для Shell, Python и C — на странице «События устройства».

Открыть события устройства