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

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

У каждого устройства Subrose есть свой контракт в 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 · Дальше по цепочке

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

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

Сигнал нода шлёт, когда сообщение проходит через неё. Сообщение от контракта к контракту идёт мимо, поэтому без слежения за блоками TON (настройка ноды chainWatch, по умолчанию выключена) устройство узнает об изменении по таймеру.

Само состояние в сигнале не передаётся. Агент запрашивает его у ноды и разбирает запись контракта, а криптографическая проверка этой записи на подходе. Прочитав свежий 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 · Вопросы

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

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

Начать

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

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

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