Привет,
На днях я потратил 3 дня на попытку мигрирвать свой проект с Hardhat v2.22.19 и Solidity v0.8.28 на Hardhat v3.1.6 и Solidity v0.8.33 (ради transient storage); затем понял, что мажор Hardhat менять не стоит и, если хочется обновиться, то можно обновить минор до v2.28.4; но в итоге всех манипуляций пришел к стандартному выводу: "если работает - не трогай" (*сарказм).
Однако как бы не так. Пока писал этот текст, новые версии библиотек подъехали, некоторые баги исчезли, и приложение ... вдруг заработало. Я в шоке и морально изношен. Поэтому вопрос "мигрировать или не стоит" для меня опять актуален - пересплю с этой идеей.
Вполне возможно, что вы спокойно работаете на последних версиях хардхата и солидити, я "подружился с ними" не с первой попытки. При этом, если вы знаете, где я мог лучше настроить свою систему - буду признателен за комментарии. Ниже - мой путь и костыли, которыми я подпирал свою миграцию.
*заранее приношу свои извинения за смесь англицизмов и russizmov.
Пару слов о приложении и версиях библиотек.
Это chrome browser extension, которое общается с блокчейном (читает/пишет) через Metamask (точнее, между ними есть еще metamask-extension-provider). Тестирую я это сперва на локальной ноде от хардхата (запускаю в отдельном терминале, из другого накатываю скрипты, потом тестриую из приложения), потом уже в сети Amoy, потом уже в Ethereum. Данную версию своего приложения начинал писать прошлым летом, поэтому у меня используется Hardhat v2.22.19 и Solidity v0.8.28. Хотел было обновиться, чтобы начать использовать transient storage, да вот не получилось. Да и нет критической необходимости в transient. надо снова подумать.
Мой environment - mac, cursor, yarn.
Погнали!
Выбор пути
Есть два стула - 1) сделать апгрейд текущего приложения и его библиотек прямиком из кода приложения, 2) создать с нуля темплейт приложения на нужных библиотеках, перенести туда свой код, и смержить, если надо. Я пошел по второму пути: не фанат версионности библиотек js, когда одни разрабы обновились до последней фичи, а другие нет, и ты сиди и придумывай, как этот зоопарк запустить (даже на текущей задаче, каждый день, когда я начинаю вот этот перенос с нуля, изменяются версии библиотек, и одни баги/варнинги исчезают, другие появляются 🫣).
Инициали��ируем hardhat v3 проект.
Сначала о версии: почему я хочу hardhat v3.1.6 ради Solidity v0.8.33, если при том же мажоре hardhat v2.28.4, согласно их релизам, они поддерживают нужную версию Солидити уже в hardhat v2.28.2 ? Ответ: нет, не поддерживают. Во-первых, появится варнинг, что "не до конца поддерживают", хоть и скомпилится (если нет transient в коде). Во-вторых, transient упадет в ошибку и не даст скомпилить.
Собственно, открываем курсор (или свою любимую ide) в новой папке, инициализируем новый проект вашим любимым способом. Я буду использовать yarn.
Ставим hardhat: yarn dlx hardhat --init, версию выбираем "Hardhat 3 beta", тип проекта я выбрал "Typescript project using Mocha and Ethers.js", ставим зависимости, yes, yes, yes.
Быстро проверяем, что проект собирается:
yarn yarn hardhat compile --force yarn hardhat test yarn hardhat node
Ничего не падает, тесты зеленые, что-то запускается.
А, нет, простите извините) Чуть не забыл. В версии Solidity 0.8.13 было критическое обновление, и теперь нельзя писать в коде
function myFunc() external { assembly { ... } }
надо
function myFunc() external { assembly ("memory-safe") { ... } }
При этом, одна из зависимостей hardhat - forge-std еще не обновлена в шаблоне, поэтому при билде проекта эта зависимость будет выдавать варнинги. Поэтому в package.json надо вручную изменить ее на "forge-std": "foundry-rs/forge-std#v1.14.0"(в версии 1.14.0 уже есть необходимые нам изменения).
Дополнительно, я еще ставлю yarn add -D @solidstate/hardhat-contract-sizer, чтобы видеть размер контрактов. После установки надо добавить этот плагин в hardhat.config.ts, а так же в конфиге можно задать его настройки:
import hardhatContractSizer from '@solidstate/hardhat-contract-sizer' const config: HardhatUserConfig = { plugins: [ ..., hardhatContractSizer ], solidity: { ... }, networks: { ... }, contractSizer: { alphaSort: false, runOnCompile: false, flat: false, strict: false, only: [], except: [], unit: 'KiB' } }
При этом, изменились некоторые команды, например команда для просмотра размера контрактов. Все доступные команды можно посмотреть через yarn hardhat --help - собственно, размер теперь посмотреть как yarn hardhat contract-size list . Однако просмотр размера так просто не запустится, будем исправлять ниже.
Настройка HardhatUserConfig
Для начала отмечу, что версии hadrhat 2 и 3 имеют ряд критических несовместимостей. Две из них, которые меня выбесили - это 1) обновленная структура hardhat.config.ts; 2) возможность делать несколько подключений к сетям (пришлось изменять логику скриптов). Если конфиг еще можно с помощью ЧатЖПТ поправить (гуглить тоже пришлось), а вот возможность нескольких подключений заставит вас мыслить иначе при написании скриптов.
Плаг hardhat-contract-sizerмы уже добавили, идем дальше.
Версия Solidity почему-то указана 0.8.28 - меняем на 0.8.33, попутно и в шаблонном контракте меняем:
const config: HardhatUserConfig = { plugins: [...], solidity: { profiles: { default: { version: "0.8.33" }, production: { version: "0.8.33", settings: { optimizer: { enabled: true, runs: 200, }, }, }, }, }, networks: ...
Я вынес конфиг в отдельный объект, мне так проще. Но мы смотрим на секцию solidity.profiles: дефолт и продак��ен - это хорошо, однако для hardhat-contract-sizer, который по умолчанию берет настройки из solidity.profiles.default, нужно явно указать settings, даже если выключен оптимизатор, иначе не запустится:
default: { version: "0.8.33", settings: { optimizer: { enabled: false }, }, },
Вы не поверите, я это только что сам понял, до этого просто копировал старые настройки, тоже работало:
solidity: { version: "0.8.33", settings: { optimizer: { enabled: true, runs: 1331, }, }, },
Теперь надо настроить подключение к сетям. Hardhat теперь имеет два вида сетей: edr-simulated и http. Первые - это локально запускаемые эмуляции сетей, вторая - больше как подключение к какой-то существующей сети (как мейннет, так и локальной едр). Я прям задолбался настраивать это, чтобы у меня был мой список аккаунтов для локальной сети, и чтобы я мог по-человечески это запускать. В итоге:
1) вы по-прежнему можете выбирать сеть при запуске как --network networkName
2) запустить вы можете только edr-simulated сеть
3) по умолчанию, запускается сеть с названием default, и настраивать ее надо тоже через это имя
4) в таком типе сети нельзя задать url для подключения, но можно задать accounts: [ { privateKey: PRIVATE_KEY_1, balance: 100500 } ], и под этими аккаунтами можно будет в ней работать
5) для подключения к сети (локальной или удаленной) надо использовать тип http
6) при подключении надо задать url и нельзя accountsчерез privateKey, но можно через mnemonic
Вот мои настройки для локальной сети и запуска на ней скриптов:
const config: HardhatUserConfig = { networks: { default: { type: "edr-simulated", chainType: "l1", chainId: 127002, accounts: [ { privateKey: 'PRIVATE_KEY_1', balance: '40200000000000000000000' }, { privateKey: 'PRIVATE_KEY_2', balance: '40200000000000000000000' }, { privateKey: 'PRIVATE_KEY_3', balance: '40200000000000000000000' } ], // mining: { // auto: false, // interval: 5000, // 1 sec // } }, localhost: { type: "http", chainType: "l1", url: "http://127.0.0.1:8545", }, amoy: { type: "http", chainType: "l1", chainId: 80002, url: "https://rpc-amoy.polygon.technology", accounts: [keys.amoy], // not tested yet }, ethereum: { type: "http", chainType: "l1", chainId: 1, url: "https://eth.drpc.org", accounts: [keys.ethereum], // not tested yet }, }, }
Теперь я могу в одном терминале запустить ноду: yarn hardhat node, а в другом - скрипты на этой ноде: yarn hardhat run scripts/myscript.ts --network localhost
(вообще, не совсем так, но до скриптов мы еще дойдем)
Так же я указываю пути к артефактам и типам (какой-то из путей я не смог перенести, не помню, но код ниже работает). Если будете копировать, то папку compiled надо будет добавить в .gitignore
paths: { sources: './contracts', artifacts: './compiled/artifacts', cache: './compiled/cache', ignition: './ignition', tests: './test', }, typechain: { outDir: "./compiled/types", },
Настройка tsconfig.json
Еще один файл в корне - настройка ts. Поскольку module теперь node16, то надо явно разрешить импорт из json и ts, если у вас оно используется.
{ "compilerOptions": { "lib": ["es2023"], "module": "node16", "target": "es2022", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "moduleResolution": "node16", "outDir": "dist", "allowImportingTsExtensions": true, // эту штуку добавил для импорта ts "noEmit": true, // а эта штука нужна, чтобы предыдущая заработала (там есть другая опция, мне эта понравилась больше) "resolveJsonModule": true, // эту штуку добавил для импорта js "strictNullChecks": false, // этот доп, чтобы не ругалось на нулл } }
Так-с. Вроде, в корне приложения это все изменения... Можем снова проверить, работают ли основные команды. Удаляем все временные папки, и снова
yarn yarn hardhat compile --force yarn hardhat test yarn hardhat node yarn hardhat contract-size list yarn hardhat test --coverage
Ура, работает! Но нет. Однако, это хорошая точка, чтобы закоммитить текущие изменения.
[upd] Во время написания статьи, coverage стал бросать варнинг, что library __HardhatCoverage имеет assembly (:facepalm:)
Переносим смарт контракты
Собственно, можно копировать свои Solidity файлы в папку contracts. При этом, у меня еще используются пара зависимостей от OpenZeppelin, поэтому я их тоже ставлю:
yarn add "@openzeppelin/contracts" "@openzeppelin/contracts-upgradeable"
Помним про assembly ("memory-safe") и обновляем по проекту. Так же устанавливаем версию Solidity на 0.8.33 в смартах. Запускаем наши основные команды (да, я параноик, но именно это иногда спасает). Но в частности, нас интересует yarn hardhat test --coverage - сюрприз, она упадет.
[UPD] не упал. Зуб даю, на днях оно не работало! Суть косяка была проста: когда работает этот coverage, и у вас при этом есть папки и подпапки для смартов, то coverage не создает эти подпапки, а пытается записать в несуществующую папку файл отчетов. Поэтому мне понадобилось попросить у ЧатЖПТ, чтобы пофиксил этот баг, и он создал вот такой скрипт (чуть ниже).
[UPD] В период с 1 на 2 февраля, вышло несколько новых патчей для библиотек - подтверждаю, что на "старых" версиях патчей баг присутствует, на новых - нет. После скрипта укажу эти библиотеки.
import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const contractsDir = path.join(__dirname, "../..", "contracts"); const coverageHtmlDir = path.join(__dirname, "../..", "coverage", "html"); function walk(dir, base = "") { const items = fs.readdirSync(dir, { withFileTypes: true }); for (const item of items) { if (item.isDirectory()) { const rel = path.join(base, item.name); const full = path.join(dir, item.name); const target = path.join(coverageHtmlDir, rel); fs.mkdirSync(target, { recursive: true }); walk(full, rel); } } } fs.mkdirSync(coverageHtmlDir, { recursive: true }); walk(contractsDir); console.log("✅ Precreated coverage HTML folders");
и запускался он вот так node scripts/system/makeCoverageDirs.js .
Обновленные библиотеки, которые "исправляют" (наскоро латают) косячное поведение hardhat coverage. При этом, поведение coverage не однозначное: если у вас есть папка contracts, и далее в ней снова папки, и только потом смарты - то coverage создаст стату в папке coverage/html/ и дальше сразу ваши папки из contracts. Если же у вас есть контракт по пути contracts/MyContract.sol, то coverage будет уже в coverage/html/contracts/ 🫣
{ devDependencies: { "@nomicfoundation/hardhat-keystore": "^3.0.4", // ^3.0.2 "@nomicfoundation/hardhat-typechain": "^3.0.2", // ^3.0.0 "@nomicfoundation/hardhat-verify": "^3.0.9", // ^3.0.8 "hardhat": "^3.1.6", // ^3.1.5 } }
Проверяем работоспособность и коммитим.
Переносим скрипты
1. Поскольку у нас в tsconfig указано "module": "node16" , то наши импорты должны явно указывать расширение файла, из которого производится импорт, а так же файл index.js, если импорт был раньше "из папки". При этом импорты type должны быть тоже строго type (есть некоторые особенности, но типа рекомендуется все же всегда указывать type):
import { NFTABI } from '../abis.js' // добавлено .js import type { Signer } from 'ethers' import type { Test1Facet } from '../../compiled/types/index.js' // + /index.js
2. Раньше был HardhatEthersSigner, теперь это Signer
3. Раньше скрипт мог одновременно импортить функции, а так же запускаться сам, для этого в конце файлов можно было написать:
export async function main() {...} if (require.main === module) { main(MY_CONFIG) .then(() => process.exit(0)) .catch((error) => { console.error(error); process.exit(1); }); }
Теперь так нельзя, и надо для таких случаев иметь отдельно файл с экспортами, и отдельно файл для запуска. Зато файл для запуска может прямо в корне делать await:
// файл ./deploy.run.ts import { main, DEFAULT_CONFIG } from './deploy-system.js' await main(DEFAULT_CONFIG)
4. Deployment scrtipts. Я не использую Ignition и деплою через ethers, поэтому мой стандартный deployment скрипт выглядел так:
import { ethers } from 'hardhat' import { MyContract } from '../../compiled/types' export async function deployMyContract(ownerAddress: string): Promise<MyContract> { const Factory = await ethers.getContractFactory('MyContract') const instance = await Factory.deploy(ownerAddress) await instance.waitForDeployment() const deployTx = instance.deploymentTransaction() const receipt = await deployTx?.wait() console.log(receipt) return instance }
Теперь импортить сеть и получать из нее ethers я должен вот так:
import { network } from 'hardhat' const { ethers, provider, networkHelpers } = await network.connect()
При этом, при каждом вызове await network.connect(), мы будем иметь новое подключение. Это новая фича hardhat, которая позволяет иметь в коде несколько разных подключений одновременно. Соответственно, для моих целей мне помог singleton, и потом уже импорт ethers и другого из этого singleton, и во все свои deployment функции я теперь первым параметром передаю ethers: HardhatEthers, чтобы работать в рамках одного подключения:
// ethers-singleton.ts // забавно, что здесь не ругается на отсутствие /index.js в путях импортов import { HardhatEthers } from '@nomicfoundation/hardhat-ethers/types' import { NetworkHelpers } from '@nomicfoundation/hardhat-network-helpers/types' import { network } from 'hardhat' import { EthereumProvider } from 'hardhat/types/providers' let ethersInstance: HardhatEthers | null = null let providerInstance: EthereumProvider | null = null let networkHelpersInstance: NetworkHelpers<"generic"> | null = null /** * Gets the singleton ethers instance. * Initializes it on first call and reuses the same instance for subsequent calls. */ export async function getEthers(): Promise<{ ethers: HardhatEthers, provider: EthereumProvider, networkHelpers: NetworkHelpers<"generic"> }> { if (ethersInstance === null) { const { ethers, provider, networkHelpers } = await network.connect() ethersInstance = ethers providerInstance = provider networkHelpersInstance = networkHelpers } return { ethers: ethersInstance, provider: providerInstance, networkHelpers: networkHelpersInstance } }
// получаем этот ethers как singleton в другом файле import { getEthers } from '../ethers-singleton.js' const { ethers, provider, networkHelpers } = await getEthers()
// во все деплой функции добавил `ethers: HardhatEthers` первым параметром export async function deployMyContract(ethers: HardhatEthers, ownerAddress: string): Promise...
Забегая наперед, кто использовал в тестах loadFixture - теперь она в networkHelpers.loadFixture .
Вроде, со скриптами всё. К сожалению, не могу этот шаг сейчас пройти с вами заново, поскольку у меня много скриптов, и это прям займет время. Поэтому просто сравниваю diff файлов в двух ветках.
Переносим тесты
1. В тестах можно спокойно создавать новое подключение на весь файл, поэтому эта часть упрощается. Потом этот ethers передадите в deployment функции, а loadFixture найдете в networkHelpers.
import { network } from 'hardhat' const { ethers, provider, networkHelpers } = await network.connect()
2. У меня почему-то изменились папки, в которых лежат артефакты смартов, добавилось /contracts/ как часть пути, просто обновите импорты, не критично.
3. Так же помним об обновлениях для скриптов - оно тут тоже актуально.
4. С бОльшего, это всё. Ниже еще пару импортов (для тестов), на всякий. Мне-то подсказывает Cursor, а вам, может, никто не подсказывает, ха-ха.
import { network } from 'hardhat' const { ethers, provider, networkHelpers } = await network.connect() import type { Signer } from 'ethers' import { EventLog, FunctionFragment, id, Indexed, Interface, keccak256, Log, toUtf8Bytes, ZeroAddress } from 'ethers' import { HardhatEthers, HardhatEthersProvider } from '@nomicfoundation/hardhat-ethers/types'
Так что же пошло не так?
Уже хотел футер вставить и поздравить себя с окончанием написания данной статьи, однако нет - обновились библиотеки 🤌
Тем не менее, о результатах.
Не понравилось: тесты теперь ранатся больше, чем в 2 раза дольше, чем до обновления. Можно было бы и "потерпеть", да? К тому же, раньше coverage имел более заметные отметки о том, что какую-то строку кода не затронул тестами - сейчас только светло розовый квадратик на белом фоне, вообще не особо заметно. Еще не тестил, как оно подключается к удаленной сети (мейннет / тестнет).
Не баг, а фича, из-за которого я и решил не мигрировать, заодно и статью написать.
[upd] на момент дописания статьи не воспроизводится. Возможно, ночные обновления пакетов исправили это, больше тестировать не буду.
При запуске локальной ноды, запуске на ней скриптов - все работает отлично. Приложение так же корректно читает данные из смартов. Но по какой-то причине, баланс тестового аккаунта при подключении через Метамаск был 0. Соответственно, транзакции из моего приложения не идут, поскольку Метамаск говорит, что деняк нет. При этом, проверка баланса из скриптов показывала, что баланс есть. Я и кеш метамаска чистил, и заново проходил всю миграцию, и прочее - ничего не помогало. А написал статью - обновились пакеты - и все заработало. Магия 🌈
Вместо окончания
Что ж, как вы уже поняли, мой баг перестал воспроизводиться, поэтому я снова должен подумать, стоит ли переходить на новые версии hardhat и solidity. Но уже не сегодня. 4 часа писал статью, потом обновились библиотеки, еще 4 часа экспериментов... Устал.
Надеюсь, костыли моего переезда помогут кому-нибудь из вас сохранить хоть немного нервных клеток)
О себе
Артем, 37 лет, Козерог, Лилит точно в каком-то доме. Телеграмм канала нет, есть инста, но в ней я "фотограф голых тить" , как сказал один мой товарищ, так что нечего ее здесь палить.
Вообще, я по высшему образованию "математик-программист" (2011), и с 2009 года пишу код за деньги. 11 лет C#, 9 лет JavaScrtipt, и вот уже 5 лет в web3 и Solidity (надеюсь, вы догадаетесь не складывать 11+9+5). Максимум по карьере доходил до Presale Tech Lead, и заработал (я и PM) тогда для нашей компании второй проект от заказчика. Так же мой Solidity marketplace из 60 смартов прошел аудит с 1й попытки. Открыт для новых проектов. Пообщаться можем в телеге (спец телега для таких "реклам", обычно отвечаю быстро, максимум в течение 24 часов). Кота за яйки не тяну - если не сошлись, то не сошлись.
Есть GitHub: artem-bayandin, там чуть больше про меня (на главной странице).
Код в данной статье - из коммерческого проекта, потребуется немного времени, чтобы его адаптировать для масс. Адаптирую, залью, скину ссылку (или запиню в гх). После этого еще планирую опубликовать свой BookingCalendar на Solidity - on-chain календарь для ивентов.
Акк с описанием всех моих проектов в LinkedIn заблокирован из-за рукожопых программистов... Если разблокируют, то будет здесь .
Всё 🤗
