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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
UNCX's token vesting contract lost roughly $87.7K when an attacker exploited a share-accounting flaw in release(), topUpVesting(), and cancel(). We break down the root cause, the exact attack sequence, and what builders should take away from it — without the jargon.
Autheo’s THEO token lost 92% after attackers used a Hyperlane TrustedRelayerIsm misconfiguration to mint 19 million unbacked tokens on Base and extract roughly $1.76 million. Here’s how the attack worked and why single-key security models remain dangerous for cross-chain assets.
Uniswap v4 hooks explained: the PoolManager singleton, flash accounting, and what the Cork and Bunni exploits teach us about hook security. A PandaSecurity deep dive.
An attacker forced a Uniswap V4 vault into a zero-liquidity state, redeemed one minimal share unit, and withdrew roughly 14,838.47 DAI. Panda Security breaks down the transaction, vulnerable code, root cause, and practical fixes.