MagnetPaymentsManager
Обзор
MagnetPaymentsManager — контракт приема платежей. Пользователь оплачивает инвойс в нативной валюте сети или ERC20, а контракт форвардит оплату на адрес treasury.
Ключевые свойства:
- upgradeable (UUPS);
- защита от реентранси (
ReentrancyGuardUpgradeable); - админские операции через
AccessControlUpgradeable.
Роли и права
DEFAULT_ADMIN_ROLE:- настройка
treasuryиsignatureValidator; - управление флагами
tokenAllowed; - вывод средств, которые оказались на контракте (
wthdraw); - апгрейд реализации (UUPS).
- настройка
Состояние
signatureValidator— адрес, чьей подписью подтверждаются инвойсы.treasury— адрес получателя оплат.tokenAllowed[token]— конфигурационный флаг разрешенности токена.usedSignatures[signature]— защита от повторного использования подписи.
Ограничения и безопасность
- Нет ончейн‑реестра инвойсов. Контракт не хранит статус/сумму инвойса; «истиной» является инвойс сгенерированный на бекенде и подпись
signatureValidator. - Replay‑защита по подписи. Повторная оплата с тем же
signatureневозможна (usedSignatures). - Привязка к плательщику. В подписи участвует
userAddress, поэтому подпись действительна только для конкретного отправителя (при корректной выдаче подписи бекендом). - Экспирация. Если
invoiceExpiration > 0, то платеж возможен только покаblock.timestamp < invoiceExpiration. - Переплата в native. При
token == address(0)проверяется толькоmsg.value >= amount; излишек остается на контракте и может быть выведен админом черезwthdraw. - Ограничение по токенам (allowlist).
payпроверяетtokenAllowed[token]и ревертится сTokenNotAllowed, если токен не разрешен.- В текущей реализации проверка выполняется до валидации подписи и распространяет ся также на
token == address(0). - При этом
setTokenAllowedзапрещаетtoken == address(0), поэтому оплата в нативной валюте фактически отключена (будет ревертTokenNotAllowed). Если нативные платежи нужны — это требует апгрейда контракта (например, проверять allowlist только для ERC20 или разрешитьaddress(0)в конфиге).
- В текущей реализации проверка выполняется до валидации подписи и распространяет ся также на
Ошибки
InvalidAddress
Реверт при передаче нулевого адреса в админские методы.
InvalidSignature
Реверт при некорректной подписи инвойса.
InvoiceExpired
Реверт если invoiceExpiration > 0 и текущее время >= invoiceExpiration.
SignatureAlreadyUsed
Реверт если подпись уже была использована ранее.
InvalidAmount
Реверт если сумма некорректна (например, amount == 0 для вывода, либо msg.value < amount при оплате нативной валютой).
TokenNotAllowed
Реверт если tokenAllowed[token] == false (on-chain allowlist не разрешает токен оплаты).
События
TokenAllowedChanged
event TokenAllowedChanged(address indexed token, bool allowed);
Эмитится при изменении разрешенности токена админом.
SignatureValidatorChanged
event SignatureValidatorChanged(address indexed signatureValidator);
Эмитится при смене signatureValidator.
TreasuryChanged
event TreasuryChanged(address indexed treasury);
Эмитится при смене treasury.
Withdrawal
event Withdrawal(address indexed token, address indexed to, uint amount);
Эмитится при выводе средств с контракта.
PaymentCompleted
event PaymentCompleted(string invoiceId, address indexed sender, address indexed token, uint amount);
Эмитится при успешной оплате инвойса.
Функции: инициализация и апгрейд
initialize
function initialize() public initializer
Назначение: инициализация upgradeable-контракта.
Что делает:
- выдает
DEFAULT_ADMIN_ROLEдеплоеру; - устанавливает
signatureValidator = msg.sender; - устанавливает
treasury = msg.sender.
_authorizeUpgrade
function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE)
Назначение: ограничение апгрейда реализации UUPS.
Функции: администрирование
setTokenAllowed
function setTokenAllowed(address token, bool allowed) external onlyRole(DEFAULT_ADMIN_ROLE)
Назначение: включить/выключить токен в конфиге.
setSignatureValidator
function setSignatureValidator(address _signatureValidator) external onlyRole(DEFAULT_ADMIN_ROLE)
Назначение: сменить адрес подписанта инвойсов.
setTreasury
function setTreasury(address _treasury) external onlyRole(DEFAULT_ADMIN_ROLE)
Назначение: сменить адрес получателя платежей.
wthdraw
function wthdraw(address token, address to, uint amount) external onlyRole(DEFAULT_ADMIN_ROLE)
Назначение: вывести средства, которые находятся на балансе контракта.
Когда нужно:
- пользователь отправил нативную валюту с
msg.value > amount(лишнее останется на контракте); - кто-то ошибочно перевел ERC20 напрямую на конт ракт;
- другие нештатные сценарии накопления баланса.
Функции: оплата
pay
function pay(string calldata invoiceId, uint invoiceExpiration, address token, uint amount, bytes memory signature) external payable nonReentrant
Назначение: оплатить инвойс.
Параметры инвойса, участвующие в подписи:
userAddress(оплачивающий адрес);invoiceId;invoiceExpiration(0 = без экспирации);token(address(0) = нативная валюта);amount.
Алгоритм:
- Проверяет, что токен разрешен:
require(tokenAllowed[token] || token == address(0), TokenNotAllowed()). - Валидирует подпись инвойса (см.
validateInvoiceSignature). - Если
token == address(0):- проверяет
msg.value >= amount; - переводит
amountнаtreasury.
- проверяет
- Иначе переводит
amountERC20 с пользователя наtreasuryчерезsafeTransferFrom. - Эмитит
PaymentCompleted.
Примечания:
- лишний
msg.value(сверхamount) не возвращается автоматически; - для ERC20 пользователь должен предварительно сделать
approveна контракт.
Функции: внутренняя логика
validateInvoiceSignature
function validateInvoiceSignature(address userAddress, string calldata invoiceId, uint invoiceExpiration, address token, uint amount, bytes memory signature) private
Назначение: проверить подпись инвойса и защититься от повторного использования.
Логика:
- если
invoiceExpiration > 0, проверяет срок действия; - проверяет, что
signatureеще не использовалась (usedSignatures); - строит хэш:
keccak256(abi.encodePacked(userAddress, invoiceId, invoiceExpiration, token, amount)); - применяет
toEthSignedMessageHash()иrecover(signature); - п роверяет, что recoveredSigner ==
signatureValidator; - помечает подпись как использованную.
Связь с бекендом: модуль web3-payments
Связанный документ: apps/docs/docs/engineering/Сервисы/backend/web3-payments.md.
В web3-payments модуле инвойс создается в БД, а для оплаты пользователю выдается подпись и параметры транзакции. Далее фронтенд вызывает pay в смарт‑контракте платежей.
Как параметры из web3-payments маппятся на pay
invoiceId(из БД) →invoiceId.invoiceExpiration(из ответа бекенда; 0 = без истечения) →invoiceExpiration.tokenAddress(из инвойса;ZeroAddressдля нативной валюты) →token.amount(строка на бекенде) →amount(целое число в минимальных единицах токена, уже с decimals).signature(подпись бекенда/валидатора) →signature.
Для оплаты нативной валютой фронтенд дополнительно передает value = amount (или больше, но это приведет к остаткам на контракте).
Сценарий выполнения (end-to-end)
- Создание инвойса на бекенде (любой продуктовый триггер: апгрейд NFT, покупка и т.д.) → пользователь получает
invoiceId. - Получение параметров оплаты через эндпоинт:
GET /web3-payments/invoice/signature-to-pay/:invoiceId.
- Оплата на фронтенде:
- вызов
pay(...)вMagnetPaymentsManagerс параметрами из шага 2; - если
token == address(0)— добавитьvalue.
- вызов
- Фиксация оплаты:
- контракт эмитит
PaymentCompleted(invoiceId, sender, token, amount); - крон
check-payments.job.tsподтягивает события и вызываетhandlePaymentCompleted.
- контракт эмитит
- Применение бизнес‑действия на бекенде (в зависимости от
InvoiceActions/invoice.data).
Рекомендации для интеграции
- Формируйте
amountна бекенде сразу в минимальных единицах (wei/decimals) и не пересчитывайте на фронте. - Всегда задавайте
invoiceExpiration, чтобы ограничить окно оплаты. - Следите, чтобы
signatureValidatorна контракте совпадал с ключом, которым подписывает бекенд. - Настройте on-chain allowlist: перед тем как выпускать подписи на оплату в токене
T, включитеsetTokenAllowed(T, true).