Lagrix

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

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

Смарт-контракты Lagrix работают с настоящими деньгами (USDC). Ошибка в контракте опасна: контракты неизменяемы, без ключа администратора и ключа обновления (документ 18), поэтому развёрнутый контракт нельзя исправить вовсе — ошибку закрывает только новая версия, на которую переходят участники. Поэтому каждый контракт до развёртывания проходит трёхслойную проверку: тесты Foundry, фаззинг инвариантов и статический анализ Slither.

КонтрактЧто делаетДокумент
A2AEscrow.solсделки, чеки, Time-Lock со сроком сдачи и окном спора каждой сделки, отмена, удержание комиссии, событие Receipt04, 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 Фаззинг и инварианты

Юнит-тесты проверяют сценарии, о которых подумал разработчик. Фаззинг подаёт миллионы случайных входов — крайние значения, нулевые адреса, огромные суммы — и проверяет инварианты, которые не должны нарушаться ни при какой последовательности вызовов:

  1. Платёжеспособность эскроу. Баланс USDC контракта эскроу не меньше суммы всех заблокированных сделок.
  2. Платёжеспособность комиссий. Баланс USDC контракта FeeDistributor никогда не меньше невостребованной части котлов периодов.
  3. Доли основателя нет. Комиссию не получает ни один адрес, кроме супер-узлов, прошедших минимум качества, разработчиков, названных в сделках (документ 26), и наград за доказанные нарушения.
  4. Отметки постоянны. Поставленная отметка «доказанное нарушение» не снимается никогда, и кошелёк с отметкой никогда не держит место.
  5. Лайки не уходят ниже нуля. Возврат никогда не больше оставшегося веса лайков.
  6. Негативные оценки в своих границах. Вес дизлайков, поставленных элементом в роли, никогда не больше веса его заказов в этой роли, по которым можно оставить отзыв.
  7. Каждая сделка может закончиться. Сделку, у которой срок сдачи прошёл без сдачи, заказчик всегда может вернуть, а сданную сделку после окна спора исполнитель всегда может закрыть: через 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 случайных вызовов нарушит инвариант, изменение не принимается.