Свой узел: приватная сеть на geth
- Зачем это нужно
- Узел — это программа с пятью хранилищами
- Ставим geth и проверяем, что он на месте
- genesis.json: вся сеть одним файлом
- geth init: файл превращается в блок 0
- Запуск: флаги по одному
- Кто отвечает на 8545
- Два отказа, которые встречают первыми
- Перевод: откуда берётся блок и куда девается комиссия
- Номер сети спрятан внутри подписи
- Сеть живёт на диске, блоки можно пустить по расписанию
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Куда углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Тридцать две главы подряд слово «сеть» было фигурой речи. Контракты компилировались, выводы примеров приходили откуда-то, а куда именно уезжала транзакция — не обсуждалось.
Конкурсное задание не оставляет этого на усмотрение: сеть должна быть своя, приватная, поднятая руками, и работать она обязана без интернета — в модуле нет ни доступа наружу, ни возможности что-то привезти с собой.
Эта глава — про то, как из одного файла описания и одной команды получается работающая сеть: со своими счетами, своими деньгами и своим номером. И про то, почему без этого файла следующие четыре главы читать бессмысленно: инструментам нужно, куда подключаться.
Узел — это программа с пятью хранилищами
Узел (node) — программа, которая держит у себя копию сети и умеет её продолжать. Внутри неё пять разных вещей, и путать их дорого.
Цепочка блоков. Заголовки и транзакции, сложенные в стопку. Только растёт, задним числом не переписывается.
Состояние. Балансы, код контрактов и их хранилище — то самое, из главы про слоты. Состояние не хранится в блоке: блок хранит только его отпечаток, а само состояние узел пересчитывает, применяя транзакции.
Очередь. Транзакции, которые приняты, но ещё не попали ни в один блок. Живёт в памяти; убили узел — очередь исчезла.
Хранилище ключей (keystore). Зашифрованные файлы с приватными ключами. Отдельная папка, отдельный пароль.
Дверь наружу. Один порт, по умолчанию 8545. Всё, что мы делали в главе про запросы к узлу, — стук именно в эту дверь.
Всё перечисленное лежит в одной папке — каталоге данных (datadir). Удалили папку — удалили сеть целиком: цепочку, балансы, историю. Ничего «в облаке» не остаётся, потому что облака нет.
Один компьютер спокойно держит несколько независимых сетей: у каждой свой каталог данных и свой порт. Это удобно на тренировках — испортили сеть экспериментом, удалили папку, подняли заново за десять секунд.
Ставим geth и проверяем, что он на месте
geth (go-ethereum) — официальная реализация узла, написанная на Go. Это один исполняемый файл без зависимостей: ни Node, ни Python ему не нужны.
geth version
Geth
Version: 1.17.5-stable
Architecture: arm64
Go Version: go1.26.5
Operating System: darwin
Проверка обязательна до всего остального: половина инструкций в интернете написана под версии 1.10–1.13, а там всё иначе. Ниже — всё под 1.17.
genesis.json: вся сеть одним файлом
Блок ноль (genesis) не приходит ни от кого — его пишет тот, кто создаёт сеть. Это единственный блок, содержимое которого выбирают руками; все остальные вырастают из него по правилам.
{
"config": {
"chainId": 1337,
"homesteadBlock": 0,
"eip150Block": 0,
"eip155Block": 0,
"eip158Block": 0,
"byzantiumBlock": 0,
"constantinopleBlock": 0,
"petersburgBlock": 0,
"istanbulBlock": 0,
"berlinBlock": 0,
"londonBlock": 0,
"terminalTotalDifficulty": 0,
"shanghaiTime": 0,
"cancunTime": 0,
"pragueTime": 0,
"blobSchedule": {
"cancun": { "target": 3, "max": 6, "baseFeeUpdateFraction": 3338477 },
"prague": { "target": 6, "max": 9, "baseFeeUpdateFraction": 5007716 }
}
},
"difficulty": "0x0",
"gasLimit": "0x1c9c380",
"alloc": {
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266": { "balance": "1000000000000000000000" },
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8": { "balance": "500000000000000000000" },
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC": { "balance": "0" }
}
}
Три адреса в примере выбраны не случайно. Первый и второй — стандартные учебные счета, чьи ключи опубликованы во всех руководствах; их мы дальше положим в хранилище узла. Третий — просто адрес с нулём: он показывает, что запись в alloc можно сделать и без денег.
Учебные ключи публичны: их знают все. В приватной сети на своей машине это безопасно и удобно. В любой настоящей сети адрес с таким ключом опустошают за секунды — там ключ генерируют сами и не показывают никому.
geth init: файл превращается в блок 0
geth init --datadir ./chain genesis.json
INFO [09-11|23:55:44.710] Successfully wrote genesis state database=chaindata hash=8d1502..b73a92
Теперь положим в хранилище узла ключ от первого адреса из alloc:
echo "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" > key.txt
echo "edu" > pass.txt
geth account import --datadir ./chain --password pass.txt key.txt
Address: {f39fd6e51aad88f6f4ce6ab8827279cfffb92266}
Адрес совпал с тем, что записан в alloc с тысячей эфира, — значит, ключ тот. Файл с ключом после импорта можно удалить: узел хранит его уже у себя, зашифрованным паролем.
Запуск: флаги по одному
geth --datadir ./chain --dev --password pass.txt \
--http --http.addr 127.0.0.1 --http.port 8545 --http.api eth,net,web3
Слово --dev пугает: кажется, что это игрушечный режим. На самом деле отличий от «настоящего» узла ровно три: блоки подписывает один участник, соседей искать не нужно и первый счёт открыт без пароля. Формат блоков, правила выполнения, расход газа и хранилище — те же самые.
Кто отвечает на 8545
Первая модель стучится в узел обычными запросами — теми самыми, что разбирались в главе про запросы:
── кто отвечает на порту 8545 ──
клиент : Geth/v1.17.5-stable/darwin-arm64/go1.26.5
eth_chainId : 0x539 = 1337
счета в keystore : 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 0x70997970c51812dc3a010c7d01b50e0d17dc79c8
── блок 0: тот самый genesis ──
хеш : 0x8d15024c2ea8d66936234abd1058a6d447e863c1d70d366ca58e7988a9b73a92
родитель : 0x0000000000000000000000000000000000000000000000000000000000000000
gasLimit : 30000000 ← из genesis.json: 0x1c9c380
difficulty : 0
транзакций : 0
высота цепи : 0
── балансы: ровно то, что записано в alloc ──
A 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 1000 ETH
B 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 ETH
C 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC 0 ETH
Четыре строки стоят того, чтобы на них остановиться.
chainId вернулся шестнадцатеричным — 0x539. Это 1337, ровно то, что записано в описании. Узлы отвечают числами в шестнадцатеричном виде почти всегда; библиотеки переводят сами, curl — нет.
Родитель блока 0 — сплошные нули. Больше ни у одного блока в сети такого родителя не будет: это признак самого первого.
Высота цепи — ноль. Сеть поднята, но не сделала ни одного блока. В режиме --dev по умолчанию блок появляется только тогда, когда для него есть транзакция: пустых блоков сеть не печатает.
Балансы совпали с alloc до единицы. Второй адрес получил деньги, хотя ключа от него узел не знает: alloc раздаёт по адресу, а не по ключу.
Два отказа, которые встречают первыми
── два отказа, которые встречают первыми ──
с адреса без ключа : -32000 unknown account
с запертого счёта : -32000 authentication needed: password or unlock
Оба отказа приходят на попытку сказать узлу «отправь транзакцию от такого-то адреса».
unknown account — у узла вообще нет ключа от этого адреса. Деньги на адресе есть, адрес существует, но подписать за него некому.
authentication needed: password or unlock — ключ есть, но счёт заперт. В режиме --dev открыт только первый счёт хранилища; остальные ждут отдельной разблокировки.
Из этого следует главное в главе: просить узел подписать за вас — не единственный и не основной способ.
Второй способ проще и надёжнее: транзакцию подписывают своим ключом у себя, а узлу отдают готовые байты. Узлу не нужно ни хранилища, ни пароля — он просто принимает подписанное. Именно так работают все библиотеки, и именно так через две главы будет работать Hardhat.
Перевод: откуда берётся блок и куда девается комиссия
Модель отправляет два перевода — один через хранилище узла, второй подписанным у себя:
── перевод 2 ETH с dev-счёта A: узел подписывает сам ──
хеш : 0x1e60739c7b6cc2bc5230a7923cba566e254b9b35844f94f7539a2f50ddcdb2ed
статус : 0x1 (0x1 = исполнена)
блок : 1 ← сделан ровно под неё
газа : 21000
у C стало : 2 ETH
── перевод 3 ETH с запертого B: подписываем ключом у себя ──
адрес из ключа : 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 — совпал с B: true
хеш : 0xc6d16f32f0a1153f7dbe0194d02cf42b572dcf94409e8092c4e93adab7341819
блок : 2
газа истрачено : 21000 ← простой перевод всегда столько
цена газа : 765777977 wei за единицу
комиссия : 16081337517000 wei
у B было : 500000000000000000000 wei
у B стало : 496999983918662483000 wei
списано : 3000016081337517000 wei
это 3 ETH + комиссия? true
у C стало : 5 ETH
── блок, в котором она лежит ──
номер : 2
подписал : 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 ← A, первый счёт keystore
транзакций в блоке : 1
gasUsed / gasLimit : 21000 / 30058619
цепочка родителей до нуля : 0 → 1 → 2
Блок появился ровно под транзакцию. До первого перевода высота была нулём, после первого — единица, после второго — двойка. Это и есть --dev без расписания: узел не тратит время на пустые блоки.
21 000 — не случайное число. Столько стоит любой простой перевод; это фиксированная цена самого факта транзакции, записанная в правилах сети. Всё, что дороже, — это уже работа контракта.
Проверка сошлась до последнего вей. Списано ровно 3 эфира плюс комиссия. Такую арифметику стоит уметь считать руками: на разборе «почему не хватило денег» она отвечает за минуту.
Подписал блок первый счёт хранилища. В режиме --dev он и участник, и тот, кто печатает блоки. Из этого есть неочевидное следствие: если отправлять с того же счёта, часть комиссии возвращается ему же как вознаграждение за блок, и арифметика перестаёт сходиться на глаз. Поэтому в модели проверка сделана на счёте B, который блоков не подписывает.
Прежде чем звать библиотеки, любой узел стоит проверить двумя запросами: «какой у тебя номер сети» и «какая высота цепи». Если первое не то, что вы ждёте, — вы подключились не туда. Если второе не растёт после отправки — транзакция не принята.
Номер сети спрятан внутри подписи
Отдельная модель подписывает одну и ту же транзакцию дважды — с правильным номером сети и с чужим — и отдаёт обе узлу:
подписано chainId=7777 → отказ узла: -32000 invalid sender: invalid chain id for signer: have 7777 want 1337
подписано chainId=1337 → принята: 0x0796c4c262b54c14f9bcb81fd7d302596c84f033d32d9459ef21a60e065dc22a
Номер сети не приписан к транзакции сбоку — он входит в подписываемые данные. Поэтому подпись, сделанная для сети 7777, для сети 1337 просто не является подписью: узел не может достать из неё отправителя и отвечает invalid sender.
Смысл этого правила прямой: не будь его, транзакцию, подписанную в тестовой сети, можно было бы взять и повторить в настоящей — с теми же деньгами и тем же результатом.
Практический вывод: номер сети в настройках инструмента обязан совпадать с номером узла. Это первое, что проверяют, когда «всё настроено, а транзакции отклоняются».
Сеть живёт на диске, блоки можно пустить по расписанию
Третья модель убивает узел и поднимает заново — на той же папке, но с блоками по таймеру:
── что есть в сети сейчас ──
последний блок : 2
его хеш : 0x1b213a395421c655f7f14f80da431d83b74ace3868f62dcd50a10a9671977259
баланс C : 0x4563918244f40000
папка chain/ : 2.1M на диске
── узел убит ──
порт 8545: соединение отклонено
── поднимаем заново на том же datadir, но блоки по таймеру: --dev.period 3 ──
блок 2 на месте, хеш совпал : да
баланс C : 0x4563918244f40000 ← тот же
транзакций не шлём, просто смотрим на высоту цепи:
+2 c → блок 3
+4 c → блок 4
+6 c → блок 5
+8 c → блок 5
+10 c → блок 6
+12 c → блок 7
при --dev.period 0 (по умолчанию) все эти шесть строк показали бы одно и то же число
Цепочка пережила перезапуск. Хеш блока 2 совпал, баланс не изменился: узел — это программа, а сеть — папка на диске.
--dev.period 3 заставляет печатать блок каждые три секунды, даже когда отправлять нечего. Шесть замеров дали 3, 4, 5, 5, 6, 7 — блоки идут по таймеру, а не по нашим кнопкам.
Зачем это на демонстрации: с мгновенными блоками всё происходит быстрее, чем успевает обновиться интерфейс, и разобрать, что за чем случилось, невозможно. С расписанием появляется настоящее ожидание — «транзакция отправлена, ждём подтверждения», — и рассказ становится связным.
Типичные ошибки
Отдельная модель ломает описание сети по одному полю и показывает, что говорит geth.
Забыли настройки больших вложений.
Fatal: Failed to write genesis block: invalid chain configuration: missing entry for fork "cancun" in blobSchedule
Это самая частая ошибка при переносе старого примера: в руководствах 2022 года такого поля не было вовсе. Лечится добавлением блока blobSchedule.
Сложность не ноль.
Fatal: Bad developer-mode genesis configuration: difficulty must be 0
Обратите внимание на момент: geth init эту ошибку не поймал — genesis записался успешно. Отказ пришёл только при запуске с --dev. Между двумя командами прошла минута, и связать их между собой на разборе получается не сразу.
Забыли geth init вообще.
chainId : 0x539
хеш блока 0 : 0x790ac206f4e9d3837e8284a6b81d69d832d6974d10735757ccad8ad35086d6ad
ваш хеш блока 0 : 0x8d15024c2ea8d66936234abd1058a6d447e863c1d70d366ca58e7988a9b73a92
баланс вашего A : 0x0
Это самая коварная. Узел поднялся, порт отвечает, номер сети даже совпал с вашим — потому что 1337 у --dev и так по умолчанию. А сеть другая: хеш блока 0 чужой, и ваших денег в ней нет. Выглядит как «почему-то пустой баланс», а причина — сеть не та.
Проверка одной командой — сравнить хеш блока 0 с тем, что выдал geth init. Совпал — сеть ваша.
Слова главы
- Узел — программа с копией сети: цепочка, состояние, очередь, ключи, порт.
- Каталог данных — папка, в которой лежит вся сеть; удалили её — удалили сеть.
- Блок ноль — единственный блок, который пишут руками; его хеш — отпечаток описания.
alloc— кому сколько выдать на старте: единственный источник денег в новой сети.- Номер сети — входит в подпись; чужой номер делает подпись недействительной.
- Хранилище ключей — зашифрованные ключи узла; счета в нём по умолчанию заперты.
- Режим одиночки — блоки подписывает сам узел, соседей не ищет, первый счёт открыт.
- Период блоков — либо «только когда есть транзакция», либо по таймеру.
Тренажёр: набрать вслепую
Цель: скорость ≥ 95 зн/мин, точность ≥ 88%
geth init --datadir ./chain genesis.json
На чемпионате
Требование звучит как «развернуть приватную сеть», и проверяют его не на словах: просят показать узел, счета и прохождение транзакции.
Порядок, который стоит уметь повторить не задумываясь:
genesis.json— номер сети, потолок газа, нулевая сложность,allocс вашими адресами;geth init— и запомнить хеш блока 0 из вывода: он пригодится, чтобы доказать, что сеть ваша;- импортировать ключ первого адреса в хранилище;
- запустить с открытым портом и нужным набором команд;
- проверить двумя запросами: номер сети и баланс вашего адреса.
Две вещи, которые экономят время на разборе:
- Держите
genesis.jsonв репозитории решения. Сеть по нему поднимается за десять секунд на любой машине, в том числе на чужой. - Не ставьте мгновенные блоки на показ. Три секунды на блок выглядят как работа; ноль секунд выглядит как фокус, которого никто не разглядел.
И отдельно: --dev — это не эмулятор. Эмулятор — это программа, которая делает вид, что она сеть. Здесь настоящий узел настоящей реализации протокола, просто участник в нём один.
Куда углубиться
geth attach— консоль к работающему узлу; те же запросы, но из командной строки.- Несколько узлов в одной сети — второй
datadir, второй порт, соединение через список соседей. - Способ согласия на основе подписей — когда блоки подписывает не один участник, а несколько по очереди.
- Отдельный порт для внутренних команд — зачем узлы разделяют публичный и служебный доступ.
Сводная проверка по всей главе: 8 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- go-ethereum: документация — официальное руководство по узлу.
- geth: приватные сети — genesis, флаги запуска, соединение узлов.
- geth: справочник флагов — все параметры командной строки.
- Ethereum JSON-RPC API — список методов, которыми мы стучались в узел.
- EIP-155: защита от повторного проигрывания — почему номер сети входит в подпись.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.