7. Тестирование и аудит смарт-контрактов: Foundry, Slither и фаззинг

Смарт-контракты Lagrix работают с настоящими деньгами (USDC). Ошибка в контракте опасна: контракты неизменяемы, без ключа администратора и ключа обновления (документ 18), поэтому развёрнутый контракт нельзя исправить вовсе — ошибку закрывает только новая версия, на которую переходят участники. Поэтому каждый контракт до развёртывания проходит трёхслойную проверку: тесты Foundry, фаззинг инвариантов и статический анализ Slither.
| Контракт | Что делает | Документ |
|---|---|---|
| A2AEscrow.sol | сделки, чеки, Time-Lock со сроком сдачи и окном спора каждой сделки, отмена, удержание комиссии, событие Receipt | 04, 27 |
| FeeDistributor.sol | котёл комиссий каждого периода и его деление поровну между супер-узлами, прошедшими минимум качества | 09 |
| SearchVouchers.sol | предоплаченные балансы с задержкой вывода; один ваучер на каждого получателя денег с нарастающей суммой за поиски, доставленные сообщения и тарифы первого контакта, погашение раз в период | 17, 23 |
| SuperNodeRegistry.sol | места, выбытие, отметки «доказанное нарушение», ложные доносы | 05, 06 |
| FeedbackRegistry.sol | отзывы обеих сторон, привязанные к закрытым сделкам и к отменённым заказам с комиссией, с весом — уплаченная по сделке комиссия, — по элементу и роли; каждый показатель — число и сумма, сделки без отзыва, негативные оценки (поставленные дизлайки против веса заказов, по которым можно оставить отзыв), лайки, вычтенные при отменах; адрес страницы отзыва, который автор отзыва привязывает один раз | 17, 25 |
| OfficialMarks.sol | метки «Официальный»: заявки с платой $100, выборки из девяти, выбранные случайно по схеме «обязательство и раскрытие», голоса, оспаривания и метка основателя, записанная при развёртывании | 24 |
7.1 Юнит- и интеграционные тесты (Foundry)
Тесты Foundry пишутся прямо на Solidity и выполняются со скоростью скомпилированного кода. Ключевые сценарии:
test_A2AEscrow_StateTransitions()— переходы LOCKED → COMMITTED → COMPLETED и отказ в недопустимых вызовах (например,claimTimeout()до истечения окна спора).test_Deadlines_Limits()— блокировка, у которой срок сдачи не в будущем или дальше 90 дней, или окно спора длиннее 14 дней, отклоняется.test_Deadline_LateDeliveryRejected()—commitResult()послеdeliverByотклоняется.test_ClaimRefund_AfterDeadline()—claimRefund()возвращает всю сумму и переводит сделку в REFUNDED только послеdeliverByи только без сдачи; комиссии не берёт и чека не пишет; до срока или после сдачи отклоняется.test_DeclineTask()— исполнитель отказывается до сдачи, и вся сумма возвращается без комиссии; после сдачи отказ отклоняется.test_DisputeWindow_PerDeal()— две сделки с разными окнами спора закрываются каждая по своему таймеру.test_EIP712_SignatureReplay()— подпись нельзя использовать повторно с другимtaskIdили nonce.test_FeeSplit_95_5()— при закрытии комиссия — ровно постоянная ставка (5 %, пока её не определит моделирование, и не выше 5 %), исполнитель получает остаток; при возврате комиссия не берётся.test_DeveloperShare()— если для сделки на элементе, полученном по F2F, названdeveloper, 20 % комиссии уходят на этот кошелёк, а 80 % — в котёл периода; безdeveloperвся комиссия идёт в котёл; при возврате не платится ни то, ни другое.test_FeePool_EqualShares()— комиссии периода уходят в его котёл и делятся поровну между супер-узлами, прошедшими минимум качества; стороны сделки и не прошедшие минимум не получают ничего; на следующий период ничего не переносится.test_Voucher_RunningTotal()— ваучер оплачивает только разницу между своей суммой и уже погашенной для этой пары плательщика и получателя; ваучер, сумма которого не выросла, не оплачивает ничего; ваучер без подписи плательщика отклоняется; вывод ждёт задержку, а ваучер, подписанный до вывода, всё равно оплачивается.test_Review_Weight()— отзыв требует завершённой сделки или отменённого заказа с комиссией, по одному от каждой стороны, и весит уплаченную по ним комиссию; второй отзыв той же стороны на ту же сделку отклоняется.test_Reputation_CountAndSum()— каждый показатель меняет число и сумму вместе; отзыв — лайк или дизлайк — убирает сделку из «без отзыва».test_Reputation_AllOrders()— каждый оплаченный заказ, выполненный или отменённый, добавляется во «все заказы» обеих сторон; отметка о доказанном нарушении на кошельке узла видна на каждом элементе узла.test_Reputation_NegativeRatings()— дизлайк добавляет свой вес к «поставленным дизлайкам» того, кто его поставил; каждая закрытая сделка и каждый отменённый заказ с комиссией добавляют свою комиссию к весу заказов обеих сторон, по которым можно оставить отзыв; негативные оценки, читаемые как поставленные дизлайки ÷ этот вес, остаются между 0 % и 100 %.test_ReviewPage_Once()— страницу к отзыву привязывает только сторона, которая его поставила, и один раз; вторая страница к тому же отзыву и страница к сделке, по которой отправитель не ставил отзыв, отклоняются; лайк или дизлайк, его вес и все показатели не меняются; слишком длинный адрес отклоняется.test_Cancel_RefundByLikes()— отмена в окне спора возвращаетmin(likeWeight, amount), вычитает столько же веса из лайков заказчика новым событием, отправляет остаток исполнителю за вычетом комиссии с него, записывает обеим сторонам отменённый заказ и не пишет чека; заказчик без лайков не получает ничего; возврат всей суммы не берёт комиссии и не открывает отзыва.test_NoAdminFunctions()— ни одна функция не меняет ставку, цену или порог после развёртывания.test_Downtime_NoMark()— недоступный узел не наказывается, а через 30 дней выбывает без отметки.test_ProvenViolation_MarkAndReward()— верное доказательство ставит постоянную отметку на кошелёк узла, снимает его с места, закрывает путь к новым местам и платит доказавшему награду в USDC; ложное — лишает заявителя заблокированной суммы.
// Пример: Time-Lock в A2AEscrow.t.sol — окно спора принадлежит сделке (журнал, запись 53)
function test_CannotClaimTimeoutBeforeDisputeWindow() public {
vm.prank(customer);
// блокируем 100 USDC (6 знаков после запятой) для отложенной сделки: сдать за сутки, окно спора 24 часа
escrow.lockFunds(taskId, provider, superNode, 100 * 10**6, uint40(block.timestamp + 1 days), 24 hours);
vm.prank(provider);
escrow.commitResult(taskId, resultHash, address(0)); // разработчика нет: собственный элемент исполнителя (документ 26)
// пытаемся забрать деньги через 2 часа после сдачи, до истечения 24-часового окна сделки
vm.warp(block.timestamp + 2 hours);
vm.prank(provider);
vm.expectRevert(A2AEscrow.DisputeWindowNotExpired.selector);
escrow.claimTimeout(taskId);
}7.2 Фаззинг и инварианты
Юнит-тесты проверяют сценарии, о которых подумал разработчик. Фаззинг подаёт миллионы случайных входов — крайние значения, нулевые адреса, огромные суммы — и проверяет инварианты, которые не должны нарушаться ни при какой последовательности вызовов:
- Платёжеспособность эскроу. Баланс USDC контракта эскроу не меньше суммы всех заблокированных сделок.
- Платёжеспособность комиссий. Баланс USDC контракта FeeDistributor никогда не меньше невостребованной части котлов периодов.
- Доли основателя нет. Комиссию не получает ни один адрес, кроме супер-узлов, прошедших минимум качества, разработчиков, названных в сделках (документ 26), и наград за доказанные нарушения.
- Отметки постоянны. Поставленная отметка «доказанное нарушение» не снимается никогда, и кошелёк с отметкой никогда не держит место.
- Лайки не уходят ниже нуля. Возврат никогда не больше оставшегося веса лайков.
- Негативные оценки в своих границах. Вес дизлайков, поставленных элементом в роли, никогда не больше веса его заказов в этой роли, по которым можно оставить отзыв.
- Каждая сделка может закончиться. Сделку, у которой срок сдачи прошёл без сдачи, заказчик всегда может вернуть, а сданную сделку после окна спора исполнитель всегда может закрыть: через 104 дня после блокировки у каждой сделки есть вызов, который её завершает.
function invariant_EscrowSolvency() public view {
assertGe(usdc.balanceOf(address(escrow)), escrow.totalLockedFunds(), "Escrow is insolvent");
}
function invariant_MarkedNeverActive() public view {
for (uint256 i; i < handler.markedCount(); i++) {
address wallet = handler.marked(i);
assertTrue(registry.provenViolation(wallet), "Mark was cleared");
assertFalse(registry.isActiveSuperNode(wallet), "Marked wallet holds a place");
}
}7.3 Статический анализ (Slither)
Slither переводит код в промежуточное представление и ищет типовые уязвимости:
- Повторный вход (reentrancy): защита — порядок «проверки → изменения → внешние вызовы» и
ReentrancyGuardот OpenZeppelin. - Авторизация через
tx.origin: запрещена, Slither предупреждает о любом её использовании. - Непроверенные низкоуровневые вызовы: переводы USDC — только через
SafeERC20. - Переполнения: встроенные проверки Solidity 0.8+ и строгие типы (
uint96,uint40) при упаковке полей.
7.4 Конвейер проверок
Каждое изменение в основной ветке открытого репозитория проходит одну и ту же цепочку:
[ push / pull request ]
│
├──> 1. forge fmt --check (форматирование)
├──> 2. slither . --fail-on high|medium (статический анализ)
├──> 3. forge test --gas-report (тесты и расход газа)
└──> 4. forge test --fuzz-runs 10000 (фаззинг инвариантов)Если хотя бы один из 10 000 случайных вызовов нарушит инвариант, изменение не принимается.