Привет,
На днях я потратил 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 заблокирован из-за рукожопых программистов... Если разблокируют, то будет здесь .
Всё 🤗
