Merge Logic (Объединение Alien-представлений)
Что такое Merge
Проблема фрагментации
Когда одинаковый токен (например, USDT) существует на разных EVM сетях и бриджится в TVM сеть, для каждого EVM-варианта создается отдельное alien-представление в TVM:
- USDT из сети Ethereum → alien-токен A в TVM
- USDT из сети BSC → alien-токен B в TVM
- USDT из сети Avalanche → alien-токен C в TVM
Это создает проблему фрагментации ликвидности: пользователь не может напрямую использовать токен A вместо токена B, хотя по сути это одинаковый актив.
Решение через Merge Pool
Merge Pool позволяет объединить эти alien-представления одного актива в единый пул, предоставляя:
- Swap между alien-представлениями: обмен токена A на токен B с коэффициентом 1:1 (с учетом нормализации decimals)
- Withdraw с конвертацией: вывод любого токена из пула в EVM с конвертацией decimals
Архитектура
Компоненты
| Компонент | Описание |
|---|---|
| MergePool | Основной контракт пула. Хранит список токенов с их decimals и статусом enabled. Обрабатывает burn токенов и выполняет swap или withdraw. |
| MergeRouter | Контракт-роутер, связанный с конкретным токеном. Хранит адрес MergePool, к которому относится токен. Деплоится для каждого alien-токена отдельно. |
| MergePoolPlatform | Platform-контракт для деплоя MergePool через TVM state init механизм. Принимает код MergePool и инициализирует его с заданными параметрами. |
| ProxyMultiVaultAlien | Proxy контракт, который деплоит MergePool и MergeRouter, управляет версиями, обрабатывает mint/withdraw запросы от MergePool. |
Схема взаимодействия
Описание потока:
- Пользователь сжигает (burn) токен A, передавая payload с типом операции (Swap/Withdraw) и целевым токеном
- MergePool получает callback
onAcceptTokensBurn, декодирует payload, конвертирует decimals - В зависимости от типа операции:
- Swap: MergePool вызывает
mintTokensByMergePoolна Proxy для минта целевого токена - Withdraw: MergePool вызывает
withdrawTokensToEvmByMergePoolна Proxy для создания withdraw event
- Swap: MergePool вызывает
- Proxy выполняет соответствующее действие
Канонический токен (Canon Token)
Определение
Канонический токен (canon token) — это выделенный токен в MergePool, который служит референсной точкой пула. Обычно выбирается токен из основной/наиболее ликвидной сети (например, USDT из Ethereum для пула USDT).
Назначение
Canon token используется как идентификатор пула и точка отсчёта. Нельзя удалить canon token из пула — это защищает целостность конфигурации.
Управление Canon Token
Canon token устанавливается при деплое MergePool через параметр _canonId — индекс в массиве токенов.
Canon token можно изменить через метод setCanon(address _token). Метод доступен для owner или manager. Проверяет, что токен существует в пуле и включен (enabled).
Операция Swap
Поток операции Swap
Шаг 1: Пользователь инициирует burn
Пользователь сжигает токен A, передавая payload с burnType = Swap и адресом целевого токена.
Шаг 2: MergePool декодирует и конвертирует
MergePool вызывает _convertDecimals для нормализации суммы между исходным и целевым токенами.
Шаг 3: Проверки и mint
MergePool проверяет условия (см. Проверки и ограничения) и при успехе вызывает _mintTokens(targetToken, amount, walletOwner, remainingGasTo, operationPayload) на Proxy.
Нормализация decimals
Функция _convertDecimals нормализует сумму при обмене токенов с разными decimals:
- Если decimals совпадают — сумма не меняется
- Если у исходного токена больше decimals — сумма делится на
10^(разница) - Если у исходного токена меньше decimals — сумма умножается на
10^(разница)
Примеры:
- USDT (Ethereum, 6 decimals) → USDT (BSC, 18 decimals): amount × 10^12
- USDT (BSC, 18 decimals) → USDT (Ethereum, 6 decimals): amount / 10^12
- USDT (Ethereum, 6 decimals) → USDT (Avalanche, 6 decimals): amount (без изменений)
Проверки и ограничения Swap
1. Токен не включен (disabled)
Если целевой или исходный токен отключен, swap не выполняется, вместо этого возвращаются сожженные токены
2. Сумма после конвертации = 0
Если после _convertDecimals получается 0, swap не выполняется, возвращаются исходные токены
Это может произойти при обмене очень малых сумм из токена с большим decimals в токен с малым decimals.
Пример edge case:
- Burn 1 wei токена с 18 decimals
- Target токен с 6 decimals
- Конвертация: 1 / 10^12 = 0 (integer division)
- Результат: mint обратно 1 wei исходного токена
3. Токен не существует в пуле
Если сжигается токен, не входящий в MergePool, транзакция ревертится через модификатор tokenExists
Операция Withdraw через Merge Pool
Поток EVM Withdraw
Шаг 1: Пользователь инициирует burn
Пользователь сжигает токен A, передавая payload с burnType = Withdraw, адресом целевого токена и данными для EVM (recipient, callback).
Шаг 2: MergePool декодирует и конвертирует
MergePool декодирует payload и вызывает _convertDecimals для нормализации суммы между исходным и целевым токенами.
Шаг 3: MergePool вызывает withdraw на Proxy
При burnType == BurnType.Withdraw и network == Network.EVM:
MergePool вызывает withdrawTokensToEvmByMergePool на Proxy, передавая:
tokenиamount— целевой токен и сконвертированная суммаrecipient— адрес получателя в EVMcallback— данные для callback после вывода
Метод защищён модификатором onlyMergePool.
Шаг 4: Proxy деплоит EVM Event
Proxy создаёт EVM event с целевым токеном и сконвертированной суммой. Далее relay-ноды подписывают событие и пользователь вызывает saveWithdraw*() в EVM (см. Лимиты).
Различие с обычным withdraw
| Аспект | Обычный Withdraw | Withdraw через MergePool |
|---|---|---|
| Целевой токен | Всегда тот же токен, который сжигается | Может быть любой токен из пула |
| Decimals конвертация | Не требуется | Выполняется при необходимости |
| Точка входа | Прямой burn → event | Burn → MergePool → Proxy → event |
Лимиты
MergePool не проверяет лимиты на стороне TVM. Лимиты на вывод проверяются на стороне EVM при вызове saveWithdraw*() в MultiVault — см. Лимиты.
Структуры данных
Token struct
| Поле | Тип | Описание |
|---|---|---|
decimals | uint8 | Количество десятичных знаков токена |
enabled | bool | Флаг, разрешён ли swap/withdraw для токена |
Получение decimals:
При добавлении токена MergePool запрашивает информацию через JettonUtils.getInfo. Токен отвечает callback'ом takeInfo (для EVM alien токенов) или takeInfoAlienTvm (для TVM alien токенов).
BurnType enum
| Значение | Название | Описание |
|---|---|---|
| 0 | Withdraw | Вывод на другую сеть |
| 1 | Swap | Обмен внутри TVM |
Payload структуры
Базовый payload для onAcceptTokensBurn:
| Поле | Тип | Описание |
|---|---|---|
nonce | uint32 | Уникальный идентификатор операции |
burnType | BurnType | Тип операции (Withdraw или Swap) |
targetToken | address | Целевой токен из пула |
operationPayload | TvmCell | Дополнительные данные (пустая ячейка для Swap, или данные целевой сети для Withdraw) |
remainingGasTo | address | Адрес для возврата газа |
Для Withdraw operationPayload содержит тип целевой сети и данные для неё (withdrawPayload).
Управление MergePool
Деплой
Деплой MergePool:
Функция deployMergePool(uint256 _nonce, address[] _tokens, uint256 _canonId) создаёт новый MergePool с указанным списком токенов и каноническим токеном.
Вызывается только owner или manager.
Деплой MergeRouter:
Функция deployMergeRouter(address _token) деплоит MergeRouter для конкретного alien токена.
Деплоится один MergeRouter для каждого alien токена. После деплоя на MergeRouter вызывается setPool для привязки к MergePool.
Последовательность деплоя:
- Деплой alien токенов (если не задеплоены)
- Деплой MergeRouter для каждого токена
- Деплой MergePool с списком токенов и указанием canon токена
- Привязка MergeRouter к MergePool через
setPool - Включение всех токенов через
enableAll
Управление токенами
| Функция | Доступ | Описание |
|---|---|---|
addToken(address _token) | owner/manager | Добавить токен в пул. Токен не должен существовать в пуле. Автоматически запрашивает decimals. |
removeToken(address _token) | owner/manager | Удалить токен из пула. Токен должен существовать и не быть canon токеном. |
enableToken(address _token) | owner/manager | Включить токен (разрешить swap/withdraw). Требует наличия decimals > 0. |
disableToken(address _token) | owner/manager | Отключить токен (запретить swap/withdraw). |
enableAll() | owner/manager | Включить все токены пула. Все токены должны иметь decimals > 0. |
disableAll() | owner/manager | Отключить все токены пула. |
setCanon(address _token) | owner/manager | Установить канонический токен. Токен должен быть включен. |
Права доступа
| Роль | Адрес | Возможности |
|---|---|---|
| owner | Устанавливается при деплое через Platform | Полный доступ: управление токенами, смена manager, upgrade контракта |
| manager | Устанавливается при деплое, меняется через setManager | Управление токенами (add/remove/enable/disable), установка canon |
| proxy | Static переменная, задается при деплое | Вызов acceptUpgrade, вызов callback'ов mint/withdraw |
Модификаторы доступа:
onlyOwner- только owneronlyOwnerOrManager- owner или manageronlyProxy- только proxy контракт
MergeRouter права:
| Роль | Возможности |
|---|---|
| owner | setPool, disablePool, setManager |
| manager | setPool, disablePool |
Кодирование payload
Swap payload
TIP-3:
Функция encodeMergePoolBurnSwapPayload(address _targetToken) кодирует payload для Swap:
burnType = Swap- целевой токен
- пустой
operationPayload
Jetton:
Функция encodeMergePoolBurnJettonSwapPayload(address _targetToken, address _remainingGasTo) кодирует тот же payload с добавлением nonce и remainingGasTo:
nonceburnType = Swap- целевой токен
- пустой
operationPayload remainingGasTo— адрес для возврата газа
Withdraw EVM payload
TIP-3:
Функция encodeMergePoolBurnWithdrawPayloadEvm(address _targetToken, uint160 _recipient, EvmCallback _callback) кодирует payload для Withdraw в EVM:
nonceburnType = Withdraw- целевой токен
operationPayloadсNetwork.EVM, адресом получателя и callback
Jetton:
Функция encodeMergePoolBurnJettonWithdrawPayloadEvm(address _targetToken, uint160 _recipient, EvmCallback _callback, address _remainingGasTo) кодирует тот же payload с добавлением remainingGasTo.
Коды ошибок
| Код | Константа | Описание |
|---|---|---|
| 2709 | WRONG_MERGE_POOL_NONCE | Неверный nonce MergePool при вызове методов Proxy (sender не соответствует deriveMergePool) |
| 2901 | WRONG_PROXY | Вызывающий контракт не является proxy контрактом |
| 2902 | ONLY_OWNER_OR_MANAGER | Действие доступно только owner или manager |
| 2903 | TOKEN_NOT_EXISTS | Токен не существует в пуле |
| 2904 | TOKEN_IS_CANON | Попытка удалить канонический токен |
| 2905 | TOKEN_ALREADY_EXISTS | Токен уже существует в пуле |
| 2906 | MERGE_POOL_IS_ZERO_ADDRESS | Попытка установить zero address как адрес merge pool в MergeRouter |
| 2907 | TOKEN_NOT_ENABLED | Токен не включен (disabled) |
| 2908 | TOKEN_DECIMALS_IS_ZERO | Decimals токена равны 0 (еще не получены) |
| 2909 | WRONG_CANON_ID | Неверный индекс канонического токена при деплое |
Риски и Edge Cases
| Риск/Case | Описание | Митигация |
|---|---|---|
| Decimals потеря точности | При swap из токена с большим decimals (18) в токен с малым decimals (6) малые суммы могут округлиться до 0 | MergePool проверяет amount == 0 после конвертации и возвращает исходные токены |
| Token disabled mid-swap | Токен отключается между инициацией burn и обработкой в MergePool | MergePool проверяет enabled при обработке и возвращает токены при enabled == false |
| MergeRouter с неправильным pool | MergeRouter указывает на несуществующий или неправильный MergePool | Проверка при настройке: MERGE_POOL_IS_ZERO_ADDRESS . Административная ответственность. |
| Upgrade MergePool без миграции состояния | При upgrade теряются данные токенов | Механизм acceptUpgrade сохраняет состояние |
| Race condition при enableAll | Если не все decimals получены, enableAll ревертится | Требование decimals > 0 для всех токенов |
| Burn несуществующего токена | Пользователь сжигает токен, не входящий в pool | Модификатор tokenExists ревертирует транзакцию |
| Вызов withdraw от неавторизованного контракта | Попытка вызвать withdrawTokensToEvmByMergePool не от MergePool | Модификатор onlyMergePool проверяет соответствие nonce |