Solidity Smart Contract Development Guide | PandaSecurity
Web3 Security

Solidity Smart Contract Development Guide

PandaSecurity Team •
#Solidity#Smart Contract Development#Web3 Security
Solidity smart contract development and Web3 security

There is a common way to think about Solidity development:

Write the contract, make the tests pass, deploy it, and then get an audit.

That process works.

But it is not how we think about smart contract development at PandaSecurity.

For us, security starts much earlier.

The most expensive smart contract bugs are often not caused by a missing require() or a typo in a mathematical expression. They come from something much simpler: the developer made an assumption that the contract never actually enforced.

Maybe the developer assumed that only the frontend would call a function.

Maybe they assumed a token would always transfer the exact requested amount.

Maybe they assumed an oracle price could not move significantly within one transaction.

Maybe they assumed nobody would deposit one wei before everyone else.

Maybe they assumed an administrator would always behave honestly.

On Ethereum, none of these assumptions are automatically true.

A smart contract is a public program. Anyone can call its external functions, deploy another contract specifically designed to interact with it, manipulate surrounding protocol state, and combine several seemingly harmless operations into one transaction.

The Solidity documentation makes essentially the same point: it is relatively easy to make software that behaves as expected, but much harder to ensure that nobody can use it in an unexpected way.

That difference is what makes Solidity development interesting.

And difficult.


Start With the System, Not the Contract

One of the first mistakes we see in Web3 development is starting with Solidity too early.

A team has an idea for a staking protocol, for example, and immediately starts writing:

function stake(uint256 amount) external {
    token.transferFrom(msg.sender, address(this), amount);
    balances[msg.sender] += amount;
}

The code itself is not complicated.

But the difficult questions have not been answered yet.

What does balance actually represent?

Is it the amount deposited, or the user’s claim on the pool?

Can the token rebase?

Does the token charge a transfer fee?

Can rewards be claimed before unstaking?

What happens if the reward token is the same token being staked?

Can someone donate tokens directly to the contract?

Can the contract become insolvent?

What happens if amount is zero?

What happens if the contract is paused?

What happens if the token contract calls back into this contract?

Those are not Solidity syntax questions.

They are protocol-design questions.

This is why we prefer to define the state transitions before writing the implementation.

Something as simple as:

User deposits
      ↓
Protocol records position
      ↓
Rewards accrue
      ↓
User claims rewards
      ↓
User withdraws

already tells us much more about what needs to be protected than a list of functions does.


The Most Important Solidity Skill Is Knowing What Must Never Happen

Developers often write tests like:

Alice deposits 100 tokens
Alice withdraws 100 tokens

That proves the happy path.

Security work starts somewhere else.

Ask:

What should never happen?

For a lending protocol:

A borrower should never withdraw more value than their collateral allows.

For an AMM:

A trader should never receive more value than the pricing model allows.

For a staking contract:

The protocol should never distribute more rewards than its accounting permits.

For an access-control system:

An unprivileged account should never be able to modify critical configuration.

These are much more useful than simply testing whether functions revert.

Once these properties are written down, they become the foundation for fuzz tests, invariant tests, manual review, and eventually the audit.

Ethereum’s secure-development guidance similarly recommends documenting critical security properties and using automated testing to evaluate properties around state machines, access control, arithmetic, external interactions, and standards compliance.


Solidity Makes External Contracts Look Too Friendly

One of the biggest conceptual differences between Solidity and traditional application development is composability.

In a normal application, calling a database function generally does not give the database control over your application.

In Solidity, this:

token.transfer(user, amount);

is an interaction with another program.

That program can have its own logic.

And if the call eventually transfers control to another contract, the chain can become even longer.

This is why we treat every external call as a boundary.

For example:

balances[msg.sender] -= amount;

token.safeTransfer(
    msg.sender,
    amount
);

The state change happens before the external interaction.

That ordering matters because external calls can introduce reentrancy.

The official Solidity security documentation recommends the Checks-Effects-Interactions pattern for exactly this reason: validate first, update your own state second, and interact with other contracts last.

But there is a more important lesson here.

Don’t memorize:

“Use Checks-Effects-Interactions.”

Understand why it exists.

Once you understand that control can leave your contract whenever you interact with another contract, you start seeing reentrancy risks in places that a simple checklist will miss.


Your Token Is Not Necessarily a Normal ERC-20

Another common development assumption is:

token.transferFrom(user, address(this), amount);

therefore:

the contract received `amount`

Not necessarily.

Consider a token with a transfer fee.

The user sends:

100 tokens

but the contract receives:

98 tokens

If your accounting records:

userDeposit = 100

while the contract actually received:

98

you have just created a discrepancy.

The same problem appears with rebasing tokens, unusual ERC-20 implementations, tokens with transfer restrictions, and tokens that do not behave exactly like the implementation your team tested against.

For a financial protocol, it is often useful to distinguish between:

amount requested

and:

amount actually received

For example:

uint256 beforeBalance =
    token.balanceOf(address(this));

token.safeTransferFrom(
    msg.sender,
    address(this),
    amount
);

uint256 received =
    token.balanceOf(address(this)) - beforeBalance;

Whether this is the correct solution depends on the protocol.

The important part is that the behavior is deliberate.


Most Financial Bugs Are Really Unit Bugs

Solidity has a lot of uint256.

That makes it surprisingly easy to hide unit mistakes.

Imagine:

USDC
6 decimals

and:

WETH
18 decimals

Now consider:

uint256 value =
    amount * price / 1e18;

What exactly does price represent?

USD with 18 decimals?

USD with 8 decimals?

Token A per Token B?

Token B per Token A?

The compiler cannot answer this.

The transaction can succeed while the financial calculation is completely wrong.

This is why we like to write down units during development:

amount      → token decimals
price       → 1e18
USD value   → 1e18
share price → 1e18

It looks unnecessary when the contract is small.

It becomes extremely useful when the protocol contains five different price conversions.

Solidity 0.8.x prevents many arithmetic overflow and underflow cases automatically, but that does not make arithmetic economically correct. Ethereum’s current security guidance still highlights arithmetic, oracle manipulation, and other logical assumptions as areas developers need to reason about explicitly.


Rounding Is Part of the Business Logic

Consider:

shares =
    assets * totalShares /
    totalAssets;

There is no obvious vulnerability here.

But Solidity uses integer arithmetic.

Someone gets the remainder.

The question is:

Who?

Suppose the result is:

10.9999...

and the contract stores:

10

That missing fraction has economic value.

In many systems, losing a tiny amount once is irrelevant.

But if an attacker can repeat the operation thousands of times, a rounding difference can become an attack surface.

This is particularly important for:

When reviewing financial code, we don’t just ask whether the formula is mathematically correct.

We ask:

Which side benefits from the rounding, and can the operation be repeated?

That is a much more useful question.


Don’t Make the Contract More Complicated Than It Needs to Be

Solidity gives developers a lot of power.

Libraries.

Inheritance.

Modifiers.

Assembly.

Proxies.

Hooks.

Delegatecalls.

Custom storage.

Low-level calls.

That doesn’t mean all of them belong in every project.

The Solidity documentation explicitly recommends keeping contracts small and modular and making them understandable enough for others to review.

There is a practical reason for this.

Imagine two implementations that provide exactly the same functionality.

The first is:

500 lines

The second is:

1,500 lines

The second contract is not automatically three times more dangerous.

But it has more state transitions, more interactions, more assumptions, and more places where two pieces of logic can interact unexpectedly.

Complexity is itself an attack surface.

At PandaSecurity, this is something we pay particular attention to during both development and audits.

Sometimes the best security improvement is not another security modifier.

It is removing 200 lines of unnecessary code.


Assembly Should Have a Reason

Inline assembly can be useful.

It can also make an otherwise understandable contract much harder to review.

For example:

assembly {
    ...
}

can bypass some of the abstractions developers normally rely on.

There are legitimate reasons to use assembly:

But the question should be:

What problem does assembly solve here?

If the answer is:

“It looked more efficient.”

that probably isn’t enough for production financial code.

The Ethereum security guidance has historically recommended avoiding inline assembly unless the developer has a strong understanding of the EVM and a specific reason to use it.


Upgradeability Changes the Meaning of “Immutable”

Many modern protocols are upgradeable.

That can be perfectly reasonable.

But it changes the security model.

With an immutable contract:

Deployed code
     ↓
Does not change

With a proxy:

User
 ↓
Proxy
 ↓
Implementation V1

and later:

User
 ↓
Proxy
 ↓
Implementation V2

Now the upgrade mechanism itself becomes part of the protocol’s security boundary.

You need to understand:

Who can upgrade?

before you can answer:

Is the protocol secure?

A technically perfect implementation can still be dangerous if one compromised key can replace it with arbitrary code.

This is why development documentation should explain not only what the contract does today, but also who has the power to change what it does tomorrow.


Testing Should Try to Break the Protocol

There is a point in development where adding another happy-path unit test gives diminishing returns.

For example:

function testDeposit() public {
    ...
}

is useful.

But then ask what happens when:

amount = 0

or:

amount = 1 wei

or:

amount = type(uint256).max

or:

the caller is a malicious contract

or:

the oracle changes during the operation

or:

another function was called immediately beforehand

That is where fuzzing becomes valuable.

Instead of deciding in advance that:

amount = 100

is the important case, let the testing framework explore the input space.

Then take it one step further with invariants.

Instead of asking:

Did this test pass?

ask:

Can this property ever become false?

That shift in mindset is one of the biggest differences between ordinary application testing and serious smart contract testing.


A Transaction Is Not Necessarily One Operation

Another thing we keep in mind during reviews is that attackers don’t think in terms of individual functions.

They think in sequences.

For example:

deposit()
→ donate()
→ withdraw()

or:

borrow()
→ manipulate price
→ liquidate()

or:

flashLoan()
→ swap()
→ interact with protocol
→ swap back()
→ repay()

Each function may look reasonable when reviewed independently.

The vulnerability can exist in the transition between them.

This is particularly important for DeFi.

The protocol is not really a collection of functions.

It is a state machine.

Thinking about it this way changes how you write both the code and the tests.


Don’t Leave Security Until the Audit

A common workflow looks like this:

Build everything
      ↓
Deploy testnet
      ↓
Finish frontend
      ↓
Schedule audit
      ↓
Discover architectural problems
      ↓
Rewrite contracts

That is expensive.

A better workflow is:

Architecture
     ↓
Threat model
     ↓
Implementation
     ↓
Tests
     ↓
Internal review
     ↓
Security audit
     ↓
Fixes
     ↓
Deployment

The audit should challenge the design.

It shouldn’t be the first person to discover the design.

Ethereum’s developer documentation explicitly recommends code review, testing, security analysis, and independent review as part of the development process rather than treating security as something that happens only after deployment.


What We Look For at PandaSecurity

This is also where our development and audit work overlap.

When we develop a contract, we naturally think about how another researcher would attack it.

When we audit a contract, we don’t only search for known vulnerability patterns.

We try to understand what the developer believed the system would do.

Then we compare that mental model with what the EVM actually allows.

That difference is where interesting vulnerabilities often appear.

For example:

Developer assumption:
"Only the frontend calls this."

Actual reality:
"Anyone can call the contract."

Or:

Developer assumption:
"The pool always contains exactly the recorded reserves."

Actual reality:
"Anyone can transfer tokens directly to the pool."

Or:

Developer assumption:
"The attacker cannot afford to move the market."

Actual reality:
"The attacker can use a flash loan."

Or:

Developer assumption:
"The admin is trusted."

Actual reality:
"The admin key is an EOA holding unlimited upgrade authority."

None of these are Solidity syntax errors.

They are security assumptions that were never enforced by code.

That is the type of problem we care about at PandaSecurity.


From Code That Works to Code You Can Trust

There is an important difference between:

“The contract works.”

and:

“The contract behaves correctly even when someone is actively trying to make it behave incorrectly.”

The first is a development problem.

The second is a security problem.

Good Solidity development needs both.

That means keeping the architecture understandable, making assumptions explicit, defining invariants, testing hostile behavior, minimizing unnecessary complexity, and treating every external interaction as potentially adversarial.

It also means accepting that automated tools have limits.

Static analysis is useful.

Fuzzing is useful.

Invariant testing is useful.

Formal verification can be useful.

None of them replace understanding the protocol.

The Solidity documentation itself points out that even bug-free source code does not eliminate every possible risk, including compiler or platform issues.


The PandaSecurity Approach

We think smart contract development should have security built into the process from the beginning.

That’s why PandaSecurity works across both sides of the lifecycle:

Smart Contract Development

and

Smart Contract Security Auditing

For a new protocol, that can mean helping with the contract architecture, Solidity implementation, integrations, testing, and deployment.

For an existing protocol, it can mean taking the opposite position:

Assume the system is wrong. Try to prove it.

The two approaches complement each other.

Developers learn to think like attackers.

Security researchers learn to understand the developer’s intended state machine.

And the final contract benefits from both perspectives.


Final Thoughts

The hardest part of Solidity is not learning the syntax.

The syntax is relatively small.

The difficult part is learning to reason about a public program that holds valuable assets and can be called by adversarial users in combinations you did not explicitly design.

That’s why our approach at PandaSecurity is simple:

Write less unnecessary code.

Make important assumptions explicit.

Define what must always remain true.

Test the paths an attacker would take.

And have someone independent try to break the system before users put real money into it.

A smart contract doesn’t become secure because the code looks clean.

It becomes more trustworthy when its assumptions have been challenged, its invariants have been tested, and its economic behavior has been reviewed from an attacker’s perspective.

That is the standard we aim for at PandaSecurity.

Related articles

← Back to Blog