Привет,

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

Всё 🤗