- 29 сентября 2026 г.
OpenZeppelin Security
Резюме
Область аудита
OpenZeppelin провела аудит репозитория Uniswap/token-launcher в коммите 9eff2a7.
В область аудита вошли следующие файлы:
Обзор системы
TokenLauncher — это система распределения токенов и формирования ликвидности на базе Uniswap V4, которая управляет распределением токенов с помощью настраиваемых стратегий. Она призвана обеспечить справедливое распределение токенов и немедленную ликвидность за счет сочетания ценообразования с развертыванием автоматизированной ликвидности. Независимо от того, создает ли команда новый токен через внешний фабричный контракт или использует существующий, лаунчер поддерживает полный рабочий процесс распределения и ликвидности.
TokenLauncher
Контракт TokenLauncher координирует запуск от начала и до конца. Команды могут сначала создать токен через внешний фабричный контракт (необязательно) или использовать существующий токен. Запуск начинается с вызова функции distributeToken() с конфигурацией стратегии. Затем лаунчер выполняет следующие действия:
- развертывает выбранную стратегию через ее фабрику;
- переводит настроенное количество токенов в эту стратегию;
- запускает процесс стратегии.
Это позволяет сделать лаунчер простым и согласованным, в то время как каждая стратегия реализует собственную логику за общим интерфейсом.
Стратегии распределения
LBPStrategyBasic — ценообразование и формирование ликвидности: эта стратегия разделяет предложение токенов на две части: одна часть участвует во внешнем аукционе TWAP для определения справедливой рыночной цены, а вторая зарезервирована для обеспечения ликвидности на Uniswap V4 после аукциона. Не менее 50% токенов резервируется для ликвидности (соответственно, на аукцион может быть выставлено до 50%). LBPStrategyBasic реализована как хук Uniswap V4, который предотвращает инициализацию пула до тех пор, пока результаты аукциона не будут подтверждены. По завершении аукциона он вызывает функцию стратегии validate(), которая считывает клиринговую цену и собранную валюту, проверяет их на соответствие ограничениям Uniswap V4 и сохраняет для миграции. После небольшой настроенной задержки в блоках любой желающий может вызвать migrate() для инициализации пула Uniswap V4 по найденной цене, что развертывает как полнодиапазонные, так и потенциально дополнительные односторонние позиции ликвидности за счет использования собранной валюты и токенов, зарезервированных в контракте.
MerkleClaim — распределение на основе клеймов: MerkleClaim публикует корень дерева Меркла в блокчейне и позволяет получателям запрашивать выделенные токены путем предоставления доказательств Меркла. Она масштабируется для больших наборов получателей и подходит для аирдропов или предопределенных распределений, где поиск цен не требуется.
Расширяемость: уровень распределения разработан с возможностью подключения модулей. Новые стратегии могут добавляться без изменения лаунчера путем реализации общих интерфейсов и, при необходимости, предоставления фабрики для детерминированного развертывания. Проекты могут использовать одну стратегию или комбинировать несколько стратегий для одного и того же токена.
Вспомогательные компоненты
Система опирается на небольшой набор вспомогательных контрактов и интеграций. Фабрики стратегий развертывают экземпляры стратегий, Permit2Forwarder управляет одобрениями и переводами на основе permit, а Multicall позволяет выполнять пакетную обработку при необходимости. Интеграция с Uniswap V4 встроена в основной путь: сама стратегия LBPStrategyBasic является хуком Uniswap V4, который регулирует инициализацию пула и применяет проверенные параметры аукциона, предотвращая преждевременное или вредоносное создание пула.
Развертывание пула и позиции ликвидности опираются на ядро и периферию Uniswap V4. Внешний TWAP-аукцион предоставляет клиринговую цену и собранную валюту, используемые во время миграции. При желании команды могут использовать внешние фабричные контракты токенов при создании нового токена перед распределением. В противном случае существующие токены проходят через тот же интерфейс лаунчера.
Модель безопасности и предположения о доверии
В ходе проверки были сделаны следующие предположения о доверии:
- Контракт аукциона никогда не попадет в необратимое состояние.
- Интерфейс контракта аукциона не изменится.
- Контракт аукциона всегда будет вызывать функцию validate в случае успешного завершения аукциона.
- Контракт аукциона работает так, как задумано.
- Получатель позиции (positionRecipient) ликвидности, созданной после инициализации пула, не совершит рагпул.
- Создатель токена не совершит рагпул после создания пула.
- Зависимости, находящиеся вне зоны видимости аудита, работают так, как задумано.
Рекомендации по интеграции и тестированию инвариантов
Эта кодовая база имеет множество интеграций, но ей не хватает надежного набора тестов на интеграцию и инварианты. Проверка выявила многочисленные проблемы во взаимодействии с внешними контрактами аукциона, находящимися вне зоны видимости аудита. Поскольку обе кодовые базы находятся в активной разработке, надежный набор тестов имеет решающее значение для предотвращения потери средств в краевых случаях или ошибок отказа в обслуживании после развертывания в блокчейне. Кроме того, было обнаружено, что несколько базовых успешных и неуспешных сценариев работают некорректно, что еще раз подчеркивает необходимость комплексного тестирования.
Привилегированные роли
В кодовой базе, входящей в область аудита, были выявлены следующие привилегированные роли/действия:
- Контракт MerkleClaim имеет владельца, который может вызывать функции sweep и withdraw для вывода оставшихся токенов после истечения срока клейма.
- Функция validate контракта LBPStrategyBasic может вызываться только контрактом аукциона.
Критическая степень серьезности
reserveSupply застревает в LBPStrategyBasic, если аукцион не состоялся (не перешел на следующий этап)
Когда функция distributeToken вызывается в контракте TokenLauncher, она вызывает LBPStrategyBasicFactory для развертывания контракта LBPStrategyBasic, отправляет токены в развернутый контракт и вызывает в нем функцию onTokensReceived, которая развертывает контракт аукциона. Затем аукцион проходит в соответствии с параметрами, переданными ему при создании.
Если продажи на аукционе преодолевают определенный порог от общего доступного количества токенов, то аукцион считается завершившимся успешно (состоявшимся). После окончания аукциона, если он был успешным (состоялся), участники торгов могут затребовать токены, на которые они делали ставки, и любой желающий может перевести собранные средства на адрес fundsRecipient. Функция sweepCurrency переводит средства на адрес fundsRecipient и вызывает его, если fundsRecipientData не пуста и fundsRecipient содержит код. Если аукцион не состоялся, собранная валюта возвращается участникам торгов, а общее непроданное предложение токенов на аукционе может быть передано на адрес tokensRecipient.
Для корректной работы LBPStrategyBasic требуется, чтобы fundsRecipient был самим собой, а fundsRecipientData — селектором функции validate. Таким образом, когда аукцион состоится, будет вызвана функция validate, и собранные средства будут переведены на него, чтобы можно было создать новый пул. Однако, если аукцион не состоится, функция validate вызвана не будет. Это приведет к тому, что , зарезервированные для создания пула, застрянут, так как извлечь их обратно невозможно.
Рекомендуется добавить функцию вывода средств (withdrawal), чтобы извлекать reserveSupply, если аукцион не состоялся.
Обновление: Решено в pull request #51 путем добавления функций sweepToken и sweepCurrency, которые адрес оператора может вызвать в момент sweepBlock или после него для восстановления средств. Важно отметить, что sweepBlock может быть сразу после migrationBlock, что позволяет оператору вывести все средства до того, как кто-либо сможет вызвать функцию migrate.
Высокая степень серьезности
Недостаточная проверка входных данных при создании контракта может нарушить нормальный поток использования
Без успешного выполнения функции validate миграция в пул невозможна, а функция validate может быть вызвана только контрактом Auction. Ожидается, что она будет вызвана через функцию контракта Auction после завершения аукциона. Когда вызывается sweepCurrency, накопленная валюта в Auction будет transferred переведена на fundsRecipient. Затем будет вызван адрес fundsRecipient с параметром fundsRecipientData, если он был предоставлен во время развертывания аукциона.
После этого шага следует вызвать sweepUnsoldTokens из контракта Auction, который переводит непроданные токены в Auction на tokensRecipient. Согласно ожидаемому рабочему процессу, fundsRecipient должен соответствовать контракту LBPStrategyBasic, а fundsRecipientData должен быть вызовом функции validate, в то время как tokensRecipient должен быть владельцем дистрибуции, предоставившим токены на аукцион.
Однако параметры fundsRecipient, fundsRecipientData и tokensRecipient предоставляются пользователем, и проверка их значений отсутствует. Из-за отсутствия этих проверок, если в качестве fundsRecipient указан адрес, отличный от LBPStrategyBasic, вместе с пустым fundsRecipientData, вся накопленная валюта будет переведена на этот адрес, миграция станет невозможной, и окажется заблокированным. Кроме того, обычный пользователь может указать tokensRecipient как адрес LBPStrategyBasic для согласованности с другими переменными. Поскольку контракт LBPStrategyBasic не имеет способа обработки этих токенов, это приведет к тому, что токены будут заблокированы в контракте.
Рассмотрите возможность жесткого кодирования fundsRecipient на адрес LBPStrategyBasic и fundsRecipientData на селектор функции validate во время создания аукциона. Кроме того, рассмотрите возможность запрета установки tokensRecipient на контракт LBPStrategyBasic.
Обновление: Решено в pull request #49. Команда заявила:
Исправлено путем проверки того, что fundsRecipient в данных AuctionParameters установлен на LBPStrategy.
Исправлено путем проверки того, что fundsRecipient в данных AuctionParameters установлен на LBPStrategy.
Несоответствие сигнатур между интерфейсом и реализацией функции
ПРИМЕЧАНИЕ: Эта проблема была обнаружена в pull request #5 в commit 243bad9, который включал файлы MerkleClaim, MerkleFactory и IMerkleClaim. Этот запрос на включение (pull request) изначально был частью области проверки. После того как эта проблема была сообщена и исправлена, запрос был объединен, зафиксированный коммит был изменен на , и проверка была продолжена с объединенной областью.
ПРИМЕЧАНИЕ: Эта проблема была обнаружена в в , который включал файлы MerkleClaim, MerkleFactory и IMerkleClaim. Этот запрос на включение (pull request) изначально был частью области проверки. После того как эта проблема была сообщена и исправлена, запрос был объединен, зафиксированный коммит был изменен на , и проверка была продолжена с объединенной областью.
Контракт MerkleClaimFactory наследуется от IDistributionStrategy. Однако одна из унаследованных функций имеет несоответствие сигнатуры, что препятствует развертыванию контракта.
Функция с несоответствием — это . Несоответствие возникает именно по следующим причинам:
- В функции отсутствует параметр bytes32 salt в качестве последнего параметра.
- Второй параметр в интерфейсе — uint128 totalSupply, тогда как в MerkleClaimFactory это uint256 amount.
Эти различия приводят к несоответствию как количества параметров, так и их типов, из-за чего сигнатура функции расходится.
Рассмотрите возможность добавления недостающего параметра bytes32 salt и приведения типа второго параметра в соответствие с интерфейсом.
Обновление: Решено в . Команда заявила:
Исправлено после того, как мы устранили проблемы с переназначением (remapping) в foundry и смогли провести тестирование.
Исправлено после того, как мы устранили проблемы с переназначением (remapping) в foundry и смогли провести тестирование.
Средняя степень серьезности
Возможность переполнения в функции validate
Функция validate контракта LBPStrategyBasic получает clearingPrice из контракта Auction. clearingPrice — это соотношение валюты к токену, которое используется для расчета количества обоих активов, предоставляемых в пул ликвидности во время его создания.
clearingPrice вычисляется и stored as a uint256 value в контракте Auction и возвращается в таком виде. Если возвращаемое значение clearingPrice больше, чем type(uint160).max, это вызовет скрытое переполнение при этом в функции validate. Это приведет к тому, что цена станет очень маленьким значением, что может либо привести к сбою создания пула, либо, если он будет успешным, к инициализации пула с неверной ценой.
Рассмотрите возможность добавления проверки, чтобы убедиться, что цена не превышает type(uint160).max, и обработки этого таким же образом, как и другие проверки, рекомендованные для функции validate.
Обновление: Решено в pull request #48.
Отсутствие контроля доступа в distributeToken
Начальное предложение токена чеканится на адрес , предоставленный в качестве параметра функции createToken. Следовательно, вызывающий эту функцию может выбрать чеканку токенов на любой произвольный адрес. Функция distributeToken позволяет выбирать между двумя путями, которые можно использовать для извлечения токена с помощью параметра payerIsUser: он может быть отправлен от msg.sender, или могут быть использованы токены напрямую из TokenLauncher. Однако, если токены чеканятся на TokenLauncher, любой пользователь может вызвать distributeToken с произвольным параметром distribution, поскольку в функции distributeToken отсутствует контроль доступа.
Рассмотрите возможность внедрения контроля доступа для функции distributeToken. Для такого ограничения можно использовать токена.
Обновление: Принято к сведению, не решено в pull request #52. Команда заявила:
Это сделано намеренно — задокументировано, что create и distribute должны вызываться только в рамках multicall с параметром payerIsUser, установленным в false.
Это сделано намеренно — задокументировано, что create и distribute должны вызываться только в рамках multicall с параметром payerIsUser, установленным в false.
Функция sweep неработоспособна
Функция sweep контракта MerkleClaim предназначена для перевода любых оставшихся токенов из дистрибуции владельцу после endTime. Согласно документации, эта функция должна быть доступна для вызова только владельцем.
Хотя sweep может вызвать кто угодно, внутри тела функции выполняется this.withdraw(), что является внешним вызовом функции withdraw из родительского контракта MerkleDistributorWithDeadline. Однако MerkleDistributorWithDeadline имеет модификатор onlyOwner для withdraw, поэтому она доступна для вызова только адресом владельца. Поскольку вызов осуществляется через this.withdraw(), вызывающим объектом становится address(this), что не позволяет владельцу вызвать sweep().
Единственным возможным обходным путем было бы установить владельца как address(this). Однако этот подход противоречит ожидаемой роли владельца и позволяет любому успешно вызывать sweep(). Хотя функция withdraw родительского контракта остается доступной и все еще может вызываться владельцем напрямую (поскольку она не переопределена), сама функция sweep не работает должным образом и фактически непригодна для использования.
Подумайте о выполнении delegatecall к address(this) внутри sweep.
Обновление: Решено в pull request #21 путем полного удаления функции sweep.
Отсутствие хеширования в MerkleFactory может привести к фронт-раннингу инициализации
Функция distributeToken контракта TokenLauncher msg.sender с предоставленным параметром salt для получения финального salt, используемого в initializeDistribution внутри MerkleFactory. Однако злоумышленник может опередить этот вызов, напрямую вызвав initializeDistribution из MerkleFactory с теми же salt и параметрами. Это приведет к развертыванию контракта MerkleClaim по тому же адресу, из-за чего последующий вызов distributeToken завершится с откатком (revert).
Подумайте о повторном хешировании salt, полученного от TokenLauncher, внутри initializeDistribution в сочетании с msg.sender.
Обновление: Решено в pull request #26.
Откат функции validate приведет к потере средств
Когда аукцион завершается, любой желающий может перевести собранные средства на аукциона, вызвав функцию sweepCurrency в Auction.sol. Для успешного создания пула fundsRecipient должен быть контрактом LBPStrategyBasic, а fundsRecipientData должна быть селектором функции validate.
Если этот вызов функции validate завершится неудачно, функция sweepCurrency также завершится с откатком. Это приведет к двум проблемам: средства, собранные на аукционе, застрянут в контракте Auction и не смогут быть возвращены, а застрянут в контракте LBPStrategyBasic, так как пул ликвидности не сможет быть создан.
Устранение этой проблемы неочевидно и, возможно, потребует изменений как в контракте LBPStrategyBasic, так и в Auction. Ниже представлена одна из рекомендаций:
- Измените сигнатуру функции validate так, чтобы она возвращала логическое значение (boolean). При выполнении проверок внутри функции, если условие не выполняется, вместо отката просто сохраните флаг, гарантирующий, что пул не может быть создан, и верните false. Верните валюту обратно на Auction.
- Создайте функцию вывода средств внутри LBPStrategyBasic, с помощью которой можно вывести все резервные токены, если функция validate завершилась неудачно.
- Верните средства пользователям со стороны Auction.
Рассмотрите возможность реализации мер по предотвращению зависания средств в результате отката функции validate.
Обновление: Решено в pull request #42. Команда сообщила:
Исправлено путем удаления функции validate.
Исправлено путем удаления функции validate.
Ребазируемые токены и токены с комиссией за перевод несовместимы с TokenLauncher
Контракт TokenLauncher разработан для работы с любыми токенами ERC-20. Однако использование токенов с комиссией за перевод (fee-on-transfer) или ребазируемых токенов может привести к неожиданному поведению и блокировке средств. Токены с комиссией за перевод, скорее всего, вызовут сбой при инициализации стратегии из-за проверки, реализованной в стратегиях. Количество отправленных из контракта TokenLauncher средств не будет совпадать с количеством, полученным стратегией. Поскольку onTokensReceived вызывается в distributeToken(), распределение токенов завершится с откатком.
Ребазируемые токены могут пройти инициализацию, но могут нарушить работу на более поздних этапах:
- В LBPStrategyBasic удерживает часть токенов, зарезервированных для ликвидности. Во время миграции это количество отправляется в positionManager. Если ребазирование уменьшает баланс контракта ниже reserveSupply, вызов migrate() завершится неудачно, что предотвратит создание пула и заблокирует как токены, так и валюту в стратегии.
- В MerkleClaim распределения фиксируются при развертывании. Если ребазирование уменьшает общее предложение, токенов может не хватить для последующих получателей, что приведет к неудачным клеймам.
Рекомендуется запретить использование токенов с комиссией за перевод и ребазируемых токенов, а также четко задокументировать вышеупомянутые риски для пользователей.
Обновление: Решено в . Команда сообщила:
Задокументировано, что ребазируемые токены и токены с комиссией за перевод несовместимы.
Задокументировано, что ребазируемые токены и токены с комиссией за перевод несовместимы.
Токены, потерянные для PositionManager в _createOneSidedPositionPlan
Контракт LBPStrategyBasic создает одностороннюю позицию, если после создания полнодиапазонной позиции ликвидности в нем останутся избыточные активы в токенах. Расчет деталей односторонней позиции происходит в функции _createOneSidedPositionPlan. Создание односторонней позиции пропускается в двух случаях:
- Превышен лимит максимальной ликвидности, разрешенной на тик (1, 2).
- Начальный тик находится слишком близко к верхней или нижней границе (1, 2).
В обоих случаях токены, которые должны были использоваться для односторонней позиции, остаются в PositionManager, так как все токены уже были отправлены туда. После миграции любой желающий может извлечь эти оставшиеся токены из PositionManager контракта путем выполнения действий SETTLE и TAKE через Uniswap.
Ниже представлены некоторые способы решения этой проблемы:
- Отправляйте в контракт PositionManager только необходимые токены. Если односторонняя позиция не создается, неиспользованные токены останутся в контракте стратегии. Совместите это с функцией вывода средств, вызываемой только владельцем распределения, чтобы вернуть неиспользованные токены после создания пула.
- Если все токены передаются в контракт PositionManager в начале migrate(), настройте Uniswap V4 таким образом, чтобы в случае, если односторонняя позиция ожидалась, но не была создана, последовательность в конце включала SETTLE и TAKE для соответствующего токена в пользу владельца распределения.
Подумайте о реализации одного из описанных выше подходов для предотвращения потери токенов.
Обновление: Решено в pull request #43.
Низкий уровень серьезности
Незворотный ETH
В контрактах Permit2Forwarder и Multicall невозможно вывести ETH, и отправленные на них средства будут безвозвратно потеряны.
Рассмотрите возможность удаления ключевого слова payable из функций permit и multicall контрактов Permit2Forwarder и Multicall соответственно.
Обновление: Решено в pull request #39.
Отсутствие проверок нулевого адреса
При выполнении операций с параметрами-адресами крайне важно убедиться, что адрес по ошибке не установлен в нулевой.
В кодовой базе было выявлено несколько случаев отсутствия проверок нулевого адреса:
- Операция в контракте TokenLauncher в TokenLauncher.sol.
- Операция в контракте LBPStrategyBasic в LBPStrategyBasic.sol.
- Операция в контракте MerkleClaimFactory в MerkleFactory.sol.
Всегда выполняйте проверку на нулевой адрес перед присвоением любой переменной состояния.
Обновление: Решено в pull request #40.
В функции getLBPAddress отсутствует параметр
Функция initializeDistribution в LBPStrategyBasicFactory.sol вычисляет итоговую соль CREATE2 как keccak256(abi.encode(msg.sender, salt)), однако getLBPAddress использует «сырую» соль напрямую. Это несоответствие означает, что getLBPAddress будет возвращать неверный адрес, если вызывающая сторона не выполнит предварительное хеширование отправителя с солью вне сети (off-chain). Это чревато ошибками и противоречит логике вывода адреса в сети (on-chain).
Рассмотрите возможность использования отправителя в качестве входного параметра для создания итогового хеша CREATE2.
Обновление: Исправлено в pull request #27.
Отсутствует view-функция для детерминированного предварительного вычисления адреса в MerkleClaimFactory
MerkleClaimFactory развертывает экземпляры MerkleClaim с использованием CREATE2, но не предоставляет view-функцию для предварительного вычисления результирующего адреса с теми же аргументами конструктора и солью, которые используются при развертывании. Без этой вспомогательной функции интеграторам приходится повторно реализовывать вывод адреса CREATE2 вне сети, что повышает риск неверных расчетов и операционных ошибок.
Рассмотрите возможность добавления в MerkleClaimFactory view-функции, аналогичной в LBPStrategyBasicFactory, которая возвращает предварительно вычисленный детерминированный адрес.
Обновление: Исправлено в pull request #41.
Отсутствует реализация onTokensReceived в MerkleClaim
Когда вызывается из TokenLauncher.sol, токены переводятся на контракт стратегии, и вызывается функция onTokensReceived контракта стратегии. Эта функция проверяет, получено ли ожидаемое количество токенов стратегией в LBPStrategyBasic.sol. Однако в MerkleClaim.sol эта функция пуста.
Рассмотрите возможность реализации такой же проверки в MerkleClaim.sol.
Обновление: Принято к сведению, не исправлено.
Остатки токенов (dust) теряются вместо того, чтобы быть выведенными
Во время создания пула любые дополнительные токены, которые не используются для наполнения пула, полностью теряются (1, 2, 3), хотя их можно было бы вернуть получателю позиции (positionRecipient). Хотя возврат токенов может потребовать больше газа, чем стоимость самих токенов, такой консервативный подход обеспечил бы защиту в случаях, когда количество оставшихся токенов является значительным.
Рассмотрите возможность изменения подхода и перевода остатков токенов получателю позиции.
Обновление: Принято к сведению, не исправлено в . Команда заявила:
это сделано намеренно для экономии газа, так как остатки будут минимальными — это четко указано в документации
это сделано намеренно для экономии газа, так как остатки будут минимальными — это четко указано в документации
Контракт LBPStrategyBasic развертывается с переменной totalSupply во время создания, но средства переводятся на него при последующем вызове. Функция onTokensReceived вызывается после перевода средств на контракт и проверяет, что полученные средства больше или равны totalSupply.
Если на контракт LBPStrategyBasic будут отправлены дополнительные токены, проверка все равно пройдет, но дополнительные средства не будут использованы и в конечном итоге застрянут. Рассмотрите возможность добавления в контракт функции вывода, которая позволит создателю забрать любые оставшиеся средства после успешной миграции пула.
Обновление: Исправлено в . Команда заявила:
исправлено путем предоставления адресу оператора возможности вывода токенов по истечении определенного периода времени
исправлено путем предоставления адресу оператора возможности вывода токенов по истечении определенного периода времени
Вызов onTokensReceived вне предпочтительного потока выполнения может привести к потере средств
Рассмотрите возможность использования метода pull для перевода средств или документирования этого риска в контракте.
Обновление: Исправлено в . Команда заявила:
если токены застревают, адрес оператора может вывести их, вызвав функцию sweepTokens



.png)



-1.png)

.png)