پست قبلی یک پیادهسازی انتها به انتها را شرح داد: یک قرارداد توکن حداقلی، بازسازی وضعیت خارج از زنجیره، و یک رابط کاربری React - تمام مسیر از `mint()` تا MetaMask. این پست از جایی که آن متوقف شد ادامه میدهد: چگونه چیزی مانند این را QA میکنید؟
من یک مهندس بلاکچین نیستم (هنوز)، اما الگوهای QA به خوبی در حوزههای مختلف قابل انتقال هستند، و قرض گرفتن آنچه در جای دیگری کار میکند، روشی است که من سریعترین یادگیری را دارم.
قرارداد فقط سه کار انجام میدهد: `mint`، `transfer`، و `burn`، اما حتی این برای تمرین کامل زنجیره ابزار QA کافی است: تحلیل استاتیک، تست جهش، پروفایلینگ گس، تایید رسمی.
کد در `egpivo/ethereum-account-state` است.
هرم QA بلاک چین: از تحلیل استاتیک در پایه تا تایید رسمی در بالاقبل از اضافه کردن هر چیز جدیدی، پروژه قبلاً داشت:
همه تستها قبول شدند. پوشش خوب به نظر میرسید. پس چرا زحمت بیشتر؟
زیرا "همه تستها قبول شدند" به معنای "همه باگها گرفته شدند" نیست. پوشش 100% خط هنوز هم میتواند یک باگ واقعی را از دست بدهد اگر هیچ assertion چیز درستی را بررسی نکند.
Slither(Trail of Bits) مشکلاتی را میگیرد که برای تستها نامرئی هستند: reentrancy، مقادیر برگشتی بررسی نشده، عدم تطابق رابط.
./scripts/run-qa.sh slither
نتیجه: 1 یافته متوسط: `erc20-interface`: `transfer()` مقدار `bool` برنمیگرداند.
این مورد انتظار میرود. قرارداد عمداً یک ERC20 کامل نیست: این یک ماشین وضعیت آموزشی است. اما این یافته آکادمیک نیست:
اگر بعداً کسی این توکن را در یک پروتکل وارد کند که انتظار ERC20 دارد، عدم تطابق رابط به صورت خاموش شکست میخورد. Slither اکنون آن را علامتگذاری میکند تا تصمیم آگاهانه باشد.
./scripts/run-qa.sh coverage نتیجه پوشش.
یک تابع پوشش داده نشده: `BalanceLib.gt()`. ما به این بازمیگردیم.
خروجی forge coverage: 24 تست قبول شد، جدول پوشش Token.sol./scripts/run-qa.sh gas
هزینههای گس پایه برای سه عملیات:
گس بر حسب عملیاتدر اجراهای بعدی، `forge snapshot — diff` با خط پایه مقایسه میکند. یک رگرسیون 20% گس در `transfer()` هزینه واقعی برای هر کاربر است - گرفتن آن قبل از merge ارزان است.
اینجا جایی است که موضوع جالب شد. Gambit(Certora) جهشیافتهها تولید میکند: نسخههایی از `Token.sol` با باگهای کوچک عمدی (`+=` به `-=`، `>=` به `>`، شرایط نفی شده). خط لوله مجموعه کامل تست را در برابر هر جهشیافته اجرا میکند. اگر یک جهشیافته زنده بماند (همه تستها هنوز قبول میشوند)، آن یک شکاف تست مشخص است.
./scripts/run-qa.sh mutation
نتیجه: امتیاز جهش 97.0% - 32 کشته شده، 1 زنده مانده از 33 جهشیافته.
لاگ خروجی Gambit هر جهشیافته و آنچه تغییر داده را نشان میدهد. چند مثال:
Generated mutant #7: BinaryOpMutation — Token.sol:168
totalSupply = totalSupply.add(amountBalance) → totalSupply = totalSupply.sub(amountBalance)
KILLED by test_Mint_Success
Generated mutant #19: RelationalOpMutation — Token.sol:196
if (!fromBalance.gte(amountBalance)) → if (fromBalance.gte(amountBalance))
KILLED by test_Transfer_Success
Generated mutant #28: SwapArgumentsMutation — Token.sol:81
return Balance.unwrap(a) > Balance.unwrap(b) → return Balance.unwrap(b) > Balance.unwrap(a)
SURVIVED ← هیچ تستی این را نگرفت
تست جهش Gambit: 32 کشته شده، 1 زنده مانده، امتیاز جهش 97.0%
جهشیافته زنده مانده `a > b` را به `b > a` در `BalanceLib.gt()` تعویض کرد. هیچ تستی آن را نگرفت زیرا `gt()` کد مرده است. هرگز در هیچجای `Token.sol` فراخوانی نمیشود.
پوشش 91.67% توابع را علامتگذاری کرد اما نتوانست شکاف را توضیح دهد. تست جهش این کار را کرد: `gt()` کد مرده است، هیچ چیز آن را فراخوانی نمیکند، و هیچکس متوجه نمیشود اگر اشتباه باشد.
کد مرده یا محافظت نشده در قراردادهای هوشمند سابقه واقعی دارد.
تابع قرار نبود قابل فراخوانی باشد، اما هیچکس آن فرض را تست نکرد. `gt()` ما در مقایسه بیضرر است، اما الگو یکسان است: کدی که وجود دارد اما هرگز اجرا نمیشود، کدی است که هیچکس نظارت نمیکند.
Halmos(a16z) در مورد همه ورودیهای ممکن به صورت نمادین استدلال میکند. جایی که تستهای fuzz مقادیر تصادفی را نمونهبرداری میکنند و امیدوارند به موارد لبه برخورد کنند، Halmos ویژگیها را به طور کامل اثبات میکند.
./scripts/run-qa.sh halmos
نتیجه: 9/9 تست نمادین قبول شد - همه ویژگیها برای همه ورودیها اثبات شد.
ویژگیهای تایید شده:
ویژگیهای تایید شدهیک نکته عملی: Halmos 0.3.3 از `vm.expectRevert()` پشتیبانی نمیکند، بنابراین نتوانستم تستهای revert را به روش معمولی Foundry بنویسم. راهحل یک الگوی try/catch است - اگر فراخوانی موفق شود وقتی باید برگردد، `assert(false)` اثبات را شکست میدهد:
function check_mint_reverts_on_zero_address(uint256 amount) public {
vm.assume(amount > 0);
try token.mint(address(0), amount) {
assert(false); // نباید به اینجا برسد
} catch {
// برگشت مورد انتظار - Halmos اثبات میکند این مسیر همیشه گرفته میشود
}
}
زیباترین نیست، اما کار میکند - Halmos هنوز ویژگی را برای همه ورودیها اثبات میکند. این نوع چیزی است که فقط با اجرای واقعی ابزار پیدا میکنید.
برای زمینه اینکه چرا تایید رسمی مهم است:
آسیبپذیری در کد بود، توسط هرکسی قابل بررسی، اما هیچ ابزار یا تستی قبل از استقرار آن را نگرفت. اثباتکنندههای نمادین مانند Halmos دقیقاً برای بستن آن شکاف وجود دارند - آنها نمونهبرداری نمیکنند؛ فضای ورودی را تخلیه میکنند.
خروجی Halmos: 9 تست قبول شد، 0 شکست خورد، نتایج تست نمادینفایل تست `contracts/test/Token.halmos.t.sol` است.
معماری پست اول یک لایه دامنه TypeScript دارد که ماشین وضعیت درون زنجیره را منعکس میکند. این فاز تست میکند که آیا این دو واقعاً موافق هستند.
من تستهای ویژگی fast-check را برای لایه دامنه TypeScript اضافه کردم، که آنچه فازر Foundry برای Solidity انجام میدهد را منعکس میکند:
npm test - tests/unit/property.test.ts
نتیجه: 9/9 تست ویژگی قبول شد پس از رفع یک باگ واقعی.
ویژگیهای تست شده:
fast-check یک باگ سازگاری بین لایهای واقعی در `Token.ts` `transfer()` پیدا کرد. مثال متقابل کوچک شده بلافاصله واضح بود:
Property failed after 3 tests
Shrunk 2 time(s)
Counterexample: transfer(from=0xaaa…, to=0xaaa…, amount=1n)
→ from == to (انتقال خودکار)
→ verifyInvariant() برگرداند false
انتقال خودکار (`from == to`) ثابت `sum(balances) == totalSupply` را شکست. `toBalance` قبل از بهروزرسانی `fromBalance` خوانده شد، بنابراین وقتی `from == to`، مقدار قدیمی کسر را بازنویسی کرد:
// قبل (باگدار)
const fromBalance = this.getBalance(from);
const toBalance = this.getBalance(to); // ← قدیمی وقتی from == to
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
this.accounts.set(to.getValue(), toBalance.add(amount)); // ← تفریق را بازنویسی میکند
رفع: خواندن `toBalance` پس از نوشتن `fromBalance`، مطابق با معنایی ذخیرهسازی Solidity:
// بعد (رفع شده)
const fromBalance = this.getBalance(from);
this.accounts.set(from.getValue(), fromBalance.subtract(amount));
const toBalance = this.getBalance(to); // ← اکنون مقدار بهروز شده را میخواند
this.accounts.set(to.getValue(), toBalance.add(amount));
قرارداد Solidity تحت تاثیر نبود: پس از هر نوشتن، ذخیرهسازی را دوباره میخواند. اما آینه TypeScript یک وابستگی ترتیب ظریف داشت که هیچ تست واحد موجودی پوشش نمیداد.
عدم تطابق بین لایهای در مقیاس بزرگتر فاجعهبار بوده است.
باگ انتقال خودکار ما پول کسی را از دست نمیداد، اما حالت شکست از نظر ساختاری یکسان است: دو لایه که قرار است موافق باشند، نیستند.
اجرای ابزارهای QA روی یک پروژه موجود هرگز فقط "نصب و اجرا" نیست. چند چیز قبل از اینکه کار کنند شکستند:
همه چیز از طریق دو اسکریپت اجرا میشود:
./scripts/run-qa.sh slither gas # فقط تحلیل استاتیک + گس
./scripts/run-qa.sh mutation # فقط تست جهش
./scripts/run-qa.sh all # همه چیز
هر بررسی سریع نیست. Slither و پوشش در هر commit اجرا میشوند. تست جهش و Halmos کندتر هستند - برای اجراهای هفتگی یا قبل از انتشار مناسبتر هستند.
پنج لایه QA، هر کدام یک کلاس مختلف از مشکل را میگیرند.
توضیح لایهGambit و fast-check در این دور قابل اقدامترین نتایج را دادند.
بررسیهای QA اکنون به GitHub Actions به عنوان یک خط لوله شش مرحلهای متصل شدهاند:
خط لوله CI: Build و Lint به مراحل Test، Coverage، Gas، Slither، و Audit منتشر میشودخط لوله GitHub Actions: Build و Lint همه مراحل پاییندست را دروازه میکند.
توضیح مرحلهEthereum Account State: QA Pipeline for a Minimal Token در ابتدا در Coinmonks در Medium منتشر شد، جایی که مردم با برجسته کردن و پاسخ دادن به این داستان به گفتگو ادامه میدهند.


