Crypto OS
Technical Crypto OSStage 2 · Smart Contract Engineering

T12 · Testing and Deployment

How do you find problems before real money is at stake?

Capabilities practiced
Builder
Hands on
Fork mainnet locally, interact with a real protocol, and complete a full test path.
AI Lab
Have AI generate test cases for your contract, then count which branches they cover and which edge cases they miss.

A Real-World Problem

You write a contract that accepts a stablecoin deposit, puts it into a lending protocol, and gives the user a receipt token.

You test carefully: a fake token, a fake lending protocol, 34 test cases, 97% coverage, all green.

You deploy to mainnet. The first real transaction reverts.

After an afternoon of investigation, you learn that this real stablecoin rejects a second approval when an existing allowance is nonzero. Your fake token did not behave that way because you implemented the standard, while the real token does not follow it exactly (the class of traps from T9).

You fix it and redeploy. The second transaction reverts. This time the real lending protocol has a minimum deposit for this asset, below which it rejects deposits. Your fake protocol had no such rule.

After your third deployment comes the hardest discovery: the contract’s administrator authority still belongs to the temporary address used by your deployment script. You wrote the transfer step, but nothing verified that it succeeded.

These problems share a cause:

They are not errors in your code. They are errors in your assumptions about the world, and your tests were built from those same assumptions.

97% coverage proves that your code ran. It does not prove any assumption true.

So how do you test against the real world instead of the world you imagined?

Thought Experiment

Think of prelaunch preparation as flight training, with four levels.

Level one: work it out on paper. Given wind speed, weight, and runway length, calculate the takeoff distance. Fast and cheap, it checks your formula. It cannot verify where those numbers came from. Use the wrong wind speed and a correct calculation still ends badly.

Level two: fly in a simulator. Build the airplane, runway, and tower, then run the whole procedure. You can now find problems between components, such as flaps failing after the landing gear retracts. But you built the simulated runway; it lacks potholes on the real one.

Level three: bring the actual airport into the simulator. Instead of building your own runway, import the layout, terrain, and navigation equipment of a real airport and fly there.

This is the heart of the chapter. Your code still runs locally, but it interacts with real current state: real tokens, protocols, parameters, and quirks. All three opening problems become visible here when their relevant steps run.

Level four: fly once for real, but carry sandbags. Rehearse the entire deployment flow in an environment without real money, including deployment, verification, and authority transfer.

The four levels have clear jobs:

  • The first two validate your logic.
  • The third validates your assumptions.
  • The fourth validates your process.

These are different kinds of failure. No single method covers them all.

One more point matters more than any level:

Every entry point to a contract is public. Therefore, whether it rejects correctly matters more than whether it executes correctly.

There is one expected successful path, and you will test it. There are dozens of paths that must be rejected. Omit one and anyone can call that opening directly.

Your Decision

Your contract interacts with a real protocol. How do you test before launch?

What Happens

The four options are not a ranking. They cover two independent dimensions:

Logic branch coverageDependency realismSpeedWhich opening problem can it reveal?
All mocksHighestLowestFastestNone
Deploy real dependencies locallyHighMediumMediumSometimes the first
Import real stateMediumHighestSlowFirst and second
Manual testnet runLowestHighSlowestThird

The key sentence hidden in the table:

Coverage measures how much of your code executed, not how many assumptions were tested.

A 100%-coverage suite built entirely on mocks may know nothing about real behavior. A single path running against real state may cover only 20% of branches but prevent a loss. The answer is to assign each layer a job:

  • Use mocks for logic and rejection paths because they are fast enough for many cases.
  • Use real state for integration because only it tests assumptions.
  • Rehearse the complete deployment process because deployment is code few people test.

One further implication deserves its own line: the rejection case you never wrote is the public entry point someone else will call.

Build a Model

A Launch Pipeline

  1. Write failure cases
  2. Logic tests (mocks)
  3. Integration tests (real state)
  4. Fuzzing and invariants
  5. Rehearse the deployment script
  6. Full testnet run
  7. Mainnet
Each step finds a different class of error. Skip one and that class reaches production.

Notice the first step: write failure cases before success cases. This is about sequence, not style. After writing success cases, people often feel finished.

Rejection Checklist: One Test per Constraint

Open the contract and list every place it rejects a condition. Give each its own test.

Contract constraintWhat it preventsRequired failing test
Administrator onlyAnyone taking controlCall from an ordinary address; assert rejection
Initialize only onceFront-running or repeated initializationInitialize twice; assert the second is rejected
Amount greater than zeroZero-amount operations corrupting accountingPass 0; assert rejection
Sufficient balanceExcess withdrawalWithdraw one unit more; assert rejection
Sufficient allowanceSpending someone else’s fundsTransfer without approval; assert rejection
No operations while pausedBroken emergency stopCall after pausing; assert rejection
Deadline not passedExecuting expired ordersAdvance time past deadline; assert rejection
Reentrancy protectionReentry from a callbackUse a mock contract that calls back; assert rejection

The last row is special: write a malicious contract specifically as a test tool. Its sole job is to attempt something forbidden when called back. T28 covers the full reentrancy model, but the test belongs here.

Two easily missed points:

  1. Assert the intended rejection, not just any error. Check the specific error type or message. Otherwise, a test remains green when the contract fails for an unrelated reason; false-green tests are common.
  2. Check that state stayed unchanged. A revert should undo every state change in the transaction. An extra assertion on critical state can catch subtle mistakes.

Testing Real State: Three Essentials

First, pin the block number. When pulling state from a real network, specify a block in configuration. Otherwise, tests depend on live state that changes every few seconds. A test that was green today turns red next week, and you can spend days debugging a problem outside your code.

Second, prepare identity and balances. Your test account has no funds in real state. There are two approaches:

  • Impersonate a real address holding much of the target asset. Most test frameworks can “send a transaction as any address.”
  • Write storage directly to give yourself a balance. It is faster but requires knowing the balance slot (see the storage layout in T8).

Third, assert real outcomes, not merely the absence of an error. “The transaction did not revert” tells you little. Assert how your balance changed, which receipt tokens you received, and what the protocol’s accounting became.

A complete integration test looks like this:

1. Import state from a real network at a fixed block
2. Deploy your contract
3. Give the test account real assets in the local environment
4. Record all relevant pre-interaction balances and state
5. Call your contract through the full business path
6. Assert your balance, counterparty balance, receipt quantity, and protocol accounting separately
7. Run the reverse path (withdraw or redeem) and assert that you return to the start

Step 7 is easiest to skip and often where the problem hides. Being able to deposit does not mean being able to withdraw. Only trying it proves that path.

Fuzzing and Invariants: Executable “For Every Input” Claims

An ordinary test says “for this input, expect that output.” A randomized test reverses the question: for any input, a property must always hold.

These properties fit many contracts holding funds:

Assets actually held by the contract  ≥  sum of all user accounting balances
Sum of all holder balances             =  total supply
After any sequence of operations, an account can withdraw no more than it deposited
A “deposit then immediately withdraw” sequence is no better than doing nothing
A parameter reserved for the administrator remains unchanged after 10,000 random calls

The last is underrated: point a random caller at every public entry point for ten thousand calls, then check the invariant. It is cheap and often finds sequences you never considered.

Two practical details:

  • Make failures reproducible. Record the seed and input sequence that caused the failure, then turn them into an ordinary permanent test.
  • Define invariant scope carefully. An invariant about contract balance and accounting may be affected by direct transfers that bypass your entry points. Adjust the property or acknowledge a real defect.

Deployment Is Code That Few People Test

Deployment scripts may be the project’s only code that runs once, cannot be corrected afterward, and has no tests. The third opening problem occurred here.

A sound deployment script must do the following:

The script must...Without it...
Read every parameter from configurationSwitching chains introduces quiet errors
Record each resulting address in a fileNo one can later identify which address has which version
Assert onchain state immediately after deploymentIncorrect permissions or parameters go unnoticed for months
Transfer authority as a separate step and assert itControl remains with a temporary private key
Be repeatable and idempotentMidway failure forces a full restart or duplicate deployment
Output everything needed for source verificationNo one can compare onchain code with the repository

The third row is most important. The last part of the deployment script should be assertions, not “deployment complete”:

assert contract owner == configured multisig address
assert contract admin != deployer address
assert critical parameters == configured values
assert contract is paused (if launch begins paused)
assert external dependency address == configured address, and code exists at that address

The final check is easy to miss: an external address you supplied may contain no contract on this chain. Deployment accepts it quietly, and the first user transaction fails later.

One more process rule: rehearse the deployment script against local real-state tests first. Run the same configuration and script through deployment and assertions on imported real state. That can catch the three most expensive errors—configuration, addresses, and authority—before spending money.

What It Is Called

Unit TestUnit test

Tests one function or module’s logic, replacing external dependencies with mocks.

It is fast enough for hundreds of cases. Its blind spot is that every mock reflects your own assumptions. In contracts, its greatest value lies in testing every rejection path, not just success.

Mock / StubMock or stub

A fake dependency whose behavior you control.

Its cost is replacing “how does the real dependency behave?” with “how do I think it behaves?” Every mismatch between the answers is an area your tests cannot illuminate.

Integration TestIntegration test

Tests several real components together to validate their interfaces and order of interaction.

For contracts, the key is not merely “multiple components,” but whether they are real. A test assembling many mocks is still a larger unit test.

ForkFork test

A local chain starts from the state of a real network at a fixed block, fetching data from that network when needed.

This is the chapter’s most important capability. It lets you interact with real tokens, protocols, and liquidity without spending on mainnet. One hard rule: pin the block number, or results drift over time.

FuzzingFuzzing

Repeatedly calls functions with random inputs to find inputs that break them.

An ordinary test asks, “Is this input correct?” Fuzzing asks, “Is there an input that breaks it?” Once you find one, turn it into a permanent test first.

InvariantInvariant

A property that must hold after every operation sequence, such as “assets held by the contract are at least the sum of user balances recorded.”

Invariant testing expands fuzzing across large random sequences of calls to all public entry points, checking the property afterward. It often finds surprising call orders.

Deployment ScriptDeployment script

Repeatable code for deployment, configuration, authority transfer, result assertions, and address recording.

A simple standard for judging one: does it end with assertions? A script that only prints “deployment complete” has not verified anything.

Hands-on

Hands onFork mainnet locally, interact with a real protocol, and complete an end-to-end testAny contract test framework + an RPC endpoint that can read historical stateFree. Use only a local fork and testnet; broadcast nothing to mainnet and use no private keys holding real assets

The lab costs no real money: a local fork reads real state into your computer, but all your transactions stay local. The last two steps use a testnet with faucet tokens. Generate a separate key for this exercise; never use a wallet with real assets.

The commands below are generic. Test frameworks have different flags, so check help for each command before using it.

Start a fork and pin its block number.

<your framework's fork flag> --fork-url <your archive RPC URL> --fork-block-number <a chosen block number>

Write that number in configuration or an environment variable. Do not leave it blank, or each run may use different state.

Verify the fork: read the code length at a real contract address and confirm it is nonzero. A zero length means the fork is not working; later tests would run on an empty chain.

Give your test account real assets within the fork.

Choose one method:

Method one: impersonate a holder
  Find a real address with a large amount of your target token
  Use your framework's ability to simulate transactions from any address
  Transfer some funds to your test account

Method two: write storage directly
  Locate the storage slot for the balance mapping (T8 explained the layout)
  Use your framework's ability to write any storage slot

The first is more realistic, the second faster. For your first attempt, use the first; it also verifies that the fork is connected to real state.

Write three failing cases before your first successful case.

Before testing the happy path, write:

Case 1: call with amount 0; assert the intended rejection and unchanged state
Case 2: call without an allowance; assert the intended rejection
Case 3: call an administrative function as a nonadministrator; assert the intended rejection

Assert the specific error for each, not just “failed.”

Then use the rejection table above: one case for every constraint in your contract. That alone gives your contract better rejection coverage than many launched contracts.

Complete a real end-to-end interaction.

Follow the seven steps in “Build a Model”: record pre-interaction balances and state, call your contract, assert each outcome, and run the reverse path back to the start.

Do not skip step seven: withdraw what you deposited. Many problems appear only on redemption.

This may fail once or several times. Each failure reveals a real-world quirk before launch, which is the value of the lab. Record each one for your prelaunch checklist.

Run coverage, then do not trust the number.

Record the coverage report. Then perform a more useful count:

Open the contract and count every “reject when condition is not met” rule.
Count tests that assert rejection.
The two counts should be close.

If rejection cases are far fewer than rejection conditions, high coverage is misleading: those branches were passed through, not verified.

Add one invariant and run ten thousand random calls.

Choose the most important property, often:

Assets actually held by the contract  ≥  sum of all user accounting balances

Use your framework’s randomized calls over all public entry points, with random arguments and order. Check the property after each round.

Turn any counterexample into an ordinary test. The output of fuzzing is not “it ran”; it is the input sequence it discovered.

Write a deployment script and rehearse it on the fork.

Follow the deployment table above and end with assertions. Run the script once on the fork.

Include both authority assertions: owner equals the configured address and differs from the deployer. That catches the third opening failure.

Run the entire process once on a testnet.

Use the same script and configuration format with testnet values. Deploy, run assertions, and verify source code.

Get test tokens from a faucet; no real money is involved.

Afterward ask which steps exposed something the fork did not. Usually there are one or two, such as a dependency missing on testnet. That is why this layer exists.

AI Lab

AI LabAsk AI to generate contract tests, then count the branches and boundaries it missedLevel 2 · AI Copilot

Ask in three steps. The second and third produce the real value.

Step one:
Here is my contract source. Generate a test suite for it. First list every condition that
must cause rejection and give each a test asserting rejection. Then write success-path tests.
Explain what each case checks.

Step two:
Compare the tests you just wrote against my contract line by line in a table.
For every require, revert, or permission check, which test covers it?
List checks with no corresponding test separately.

Step three:
This contract integrates with a real external protocol, but my tests use a mock.
Which classes of difference between mock and reality could make tests pass but mainnet fail?
Give one concrete example per class and explain how to verify it on a fork.

The model tends to produce many tests quickly, but its distribution can be badly skewed: detailed success paths and few rejection paths, much like code in its training data. Judge the first answer by its proportion of rejection tests, not its raw count.

Step two forces a useful comparison. Asking the model to map every source check to a test is more reliable than asking vaguely what it missed. The table becomes a concrete list of tests to add.

Step three is especially valuable and suits models well: synthesis and reasoning rather than precise recall. You saw the same boundary in the AI Labs of T9 and T11: verify facts requiring exact recall, and use a model to enumerate possible patterns.

You must do one final step yourself: test each claimed difference against the real dependency on a fork. A model’s claim about a stablecoin’s unusual approval behavior may be right or may confuse it with another token. The fork gives you a referee without risking money.

Verify these points yourself after using AI

  • You verified every test-framework API and assertion method it used in help or documentation; models invent plausible helper names
  • Count how many generated tests assert rejection; if it wrote only success paths, ask it to redo the suite
  • Compare every rejection condition in your contract with the tests and add each missing case yourself
  • Does it assert a specific error type or merely “the transaction fails”? The latter causes false-green tests
  • Does it assert that state is unchanged after rejection?
  • Are boundaries covered: 0, maximum values, tokens with 0 and 18 decimals, repeated calls in one block, and a balance exactly equal to the needed amount?
  • Do mock contracts behave like real dependencies? Verify that against the fork
  • If it wrote fork tests, did it pin the block number?
  • Do not trust a coverage number it reports; run the coverage tool yourself

Real-World Case

An unprotected initializer was called first by an attacker

An upgradeable contract exposed an initializer on its implementation without preventing repeated or unauthorized calls. Tests invoked it only during expected deployment, so they passed. Onchain, someone called the same function directly and made themselves administrator.

The shared pattern is testing only the intended caller. One test asserting that initialization can happen only once and only through the authorized path can catch this.

The mock token behaved differently from the real token

This is the opening scenario. The mock follows the standard; the real token differs in approval and return behavior.

A broader example is return values. The standard says a transfer function returns a boolean, but some early tokens return nothing. A standard-assuming caller fails while decoding that result, and the mock never reveals it.

As T9 explained, the testing remedy is simple: run against the real token on a fork and the difference appears.

A deployment script did not assert that authority had transferred

A deployment script includes “transfer owner to multisig,” but that step fails quietly due to a bad argument. The script keeps going and prints “deployment complete.” Weeks later, an authority check reveals that a hot wallet private key still controls the protocol. Anyone holding that key could have changed parameters throughout that period.

End a deployment script with assertions, not logs. Assert authority twice: who the owner is, and that the owner is not the deployer.

Fuzzing found a favorable rounding direction

All handwritten tests pass for a contract converting between shares and assets. After hundreds of thousands of random runs, fuzzing finds a tiny input whose division rounds in the user’s favor. Repeated deposits and withdrawals can slowly drain the protocol. Each loss is negligible, but the cycle can repeat indefinitely.

Rounding direction is hard for humans to exhaust but easy for machines to explore. This is a classic invariant-testing result: “deposit and withdraw is no better than doing nothing” is the property, and random sequences find a counterexample.

Change One Variable

What ifDo not pin the block number when forking

Tests that pass today turn red next week, even though your code did not change. Perhaps pool liquidity changed, governance altered a parameter, or the large holder you impersonated moved their tokens.

This is painful to debug because the failures point toward your code even though the cause lies elsewhere. One configuration line can prevent days of investigation.

What ifA dependency is upgradeable, but your pinned fork uses its old implementation

Tests run against logic that no longer exists on mainnet. They pass while live behavior differs.

Treat the fork block as a maintained dependency: periodically move it forward, run tests, and investigate new failures. A failure can signal that something you rely on changed.

At a deeper level, upgradeability of external dependencies belongs in your risk model. T28 and T29 expand on this.

What ifThe protocol you depend on does not exist on testnet

This is common during testnet rehearsal. Many protocols deploy only to mainnet, or have incomplete testnet versions.

You can deploy a simplified copy on testnet just for process rehearsal, leave functional testing to the fork, or rehearse only deployment and authority without business logic on testnet.

The third is often most practical: each layer should do the job it does best. Testnet verifies process, not the real protocol’s behavior.

What ifCoverage is 100% but no test asserts rejection

This is possible: every branch ran, but no test checks whether forbidden behavior was rejected for the right reason.

The coverage report then becomes actively misleading, encouraging you to stop asking questions.

A better indicator is the ratio between rejection conditions and tests asserting rejection. It is imperfect but at least measures something relevant.

Takeaway Questions

1
What problem does it solve?

What problem does it solve? This chapter asks how to find errors while the cost is still low. For contracts, that is urgent because after deployment, fixing a problem may be impossible rather than merely expensive.

9
Who bears the risk?

Who bears the risk? Users bear all risk from untested rejection paths and cannot know in advance. “We tested it” is not a useful public claim. Say which rejection conditions were tested, which real state was used, and what the deployment script asserted.

17
Who bears the loss when AI gets it wrong?

Who loses when AI is wrong? The AI Lab asks a model to write tests, but a suite missing a key rejection case can be more dangerous than no suite, because it quiets doubt. Run each generated case and confirm the intended error. Responsibility stays with whoever deploys.

18
Which decisions must keep a human in the loop?

Which decisions need a human in the loop? Deployment and authority transfer are clear examples. The script may execute automatically, but a human must review the configuration. Assertions let machines verify decisions you have made, not make those decisions for you.

Chapter Check

Key idea

Testing against a fork of mainnet state is closer to reality than any isolated unit test.

Finished this chapter’s lab? Mark it completeView all progress

On this page