$1,000,000 in security audit grants are live now, Apply here →

← Resource Hub

How to Write a Smart Contract Proof of Concept

Updated 7 min read

Quick answer. A smart contract proof of concept is a test that reproduces a suspected bug and shows how severe it is. Scope it in comments, reuse the test harness, then debug until the test proves the issue.

Once you have an idea for a bug, you need to prove it. A smart contract proof of concept (PoC) is the test case that shows the exact issue and how critical it is.

This guide walks through the whole process on a real finding idea in the GMX V2 codebase, from first suspicion to a passing test. It includes the dead ends, because debugging is part of every PoC.

For background on the system, read GMX V2 explained first. The code described here is GMX V2 as it stood in 2023.

Start With the Finding Idea

The idea sits in the decrease position flow. When a decrease order executes, it calls a processCollateral function.

That function decreases the collateral by the requested amount. It also realizes the trader's profit or loss (PnL) and applies its effect to the collateral and the pool's accounting.

Three details combine to create the finding.

1. The pool's side of the PnL

If the trader's PnL is negative, the code takes one branch. If the trader is in profit, it takes the else branch.

In the profit branch, pnlAmountForPool is the opposite of the position's PnL. So it is negative: it represents tokens leaving the pool to pay the trader.

2. The profit swap

If the PnL token differs from the collateral token, the code swaps the profit into the collateral token. The output token is the collateral token.

A secondary output token is used only when the two cannot be consolidated. The key point is that this swap runs through the position's own market.

Inside the swap, the code applies a delta to that market's pool amount for the tokens going out.

A swap has tokens coming in and tokens going out, and the outgoing side must leave the pool's accounting.

3. When the accounting happens

The values from processCollateral, including pnlAmountForPool, are returned to DecreasePositionUtils. That is where the negative PnL amount is applied to the pool amount.

So the accounting for the profit leaving the pool happens after the swap has already run. Picture the market as a circle:

  • With no swap: the trader's profit comes out of the pool in PnL tokens, and the trader is paid.
  • With a swap: the profit comes out of the pool in PnL tokens, goes back in as the swap's input, and collateral tokens come out.

Mid-swap, the market's balance is briefly inaccurate, since the profit has not yet been subtracted.

GMX V2 already handles that with a temporary key, so it is not the bug, and it is a distraction to avoid.

The edge case

DecreasePositionCollateralUtils has liquidation edge cases. They apply when the collateral cannot cover the fees, along with a PnL that might be negative or positive.

In those cases, the code calls getLiquidationValues. To prioritize paying funding fees when the remaining collateral is negative, it resets pnlAmountForPool.

Now take a position in profit whose profit was swapped. The PnL amount left the pool for the swap, but the reset overwrites it.

The later delta to the pool amount uses the reset value. The tokens taken out for the swap are never subtracted, so the pool's accounting breaks.

The suggested fix: before the reset, apply the delta to the pool amount for the swapped profit, only when a swap actually happened.

Scope the PoC Before Coding

Before writing any code, write out what the PoC has to do as comments. Here, the test needs a position that is in profit yet liquidatable, which large fees make possible.

  1. A trader opens a position where the PnL token is not the collateral token. That is what forces the swap.
  2. Fees build up until the position is liquidatable even while in profit. Borrowing fees accrued over time are one of many ways to do it.
  3. The liquidation executes. Then compare what happens with what you expect.

Reuse the Test Harness

Do not build setup from scratch. Copy it from the existing tests and PoCs: a user deposit into the market, then a trader opening a position.

The GMX V2 test harness is written in Hardhat, so the PoC uses Hardhat too. Rewriting the entire harness in Foundry just for one PoC is not worth it.

Check each step as you go. After opening the position, fetch the position count from the data store and confirm one position exists.

The copied test opened a long on ETH/USD backed by USDC. So the PnL token is ether and the collateral token is USDC, which is exactly the mismatch the PoC needs.

Increasing the position size makes fees build faster.

Iterate and Log

To make the position liquidatable, set the borrowing factors and exponents in the data store to switch on borrowing fees. Then advance time, here by seven days.

Finally, execute the liquidation of the long with USDC as collateral, in the right market, while the position is in a small profit.

Is that realistic? A keeper would normally liquidate long before fees built up this far.

But a trader in profit can remove collateral, because the profit counts as collateral. That leaves a position barely backed by real collateral, a questionable decision in itself that could lead to other issues.

For the PoC, building up exaggerated borrowing fees is enough to test whether the scenario can happen at all.

Debugging the first runs

The first attempts did not work. The position was not liquidatable, because validation stopped the call before it reached the interesting code.

The way through is logging.

Import Hardhat's console into the contracts and log the borrowing fee amount, the collateral amount and the remaining collateral in USD, which needs the signed integer log because it can be negative.

Expect slow loops. The GMX V2 contracts are over a hundred files, 132 in this run, and they recompile after every change.

The logs led to two fixes:

  • Realistic fee factors. The factors copied at first were off. Values taken from the project's other borrowing fee tests worked.
  • Liquidity for the swap. The swap fails without liquidity on both sides. The PoC deposited 1,000 ether and 5 million USDC, at a price of 5,000 USDC per ether.

A log of whether the swap happened confirmed the PoC was on the intended path.

Read the Result

With fees high enough and liquidity on both sides, the liquidation reverted with InvalidMarketTokenBalance.

That error comes from validateMarketTokenBalance, which runs after any order executes. It is a last-ditch safety check that the market holds at least every amount it expects.

Those amounts include collateral, impact pool amounts, claimable collateral, claimable fees and the pool amount. It is the kind of post check described in Solidity security best practices.

Because pnlAmountForPool was reset, the withdrawal for the swap was never subtracted. The pool believes it holds more tokens than it does, counting the swapped profit twice.

The safety check catches the gap and reverts. The result is a position that cannot be liquidated.

Make it a clean test

Wrap the liquidation in an expectation that it reverts with the InvalidMarketTokenBalance custom error. Run it to confirm that is exactly where it fails.

Then prove the cause, so it is not hand-waving. Logging pnlAmountForPool inside getLiquidationValues shows the negative value being overwritten with a positive one.

That overwritten negative value should have taken the tokens out of the pool. With it lost, the accounting breaks and the revert follows.

This first version of the PoC proves the issue beyond doubt for a position in small profit, liquidated after its profit was swapped. The next step is a more realistic scenario.

The PoC Process in Short

A PoC rarely comes together on the first try. You will debug, work with the test harness and log as you go.

That work pays off twice. Running and logging the code teaches you how the contracts really behave.

  1. Spot the issue and trace the exact lines involved.
  2. Scope the PoC as comments before writing code.
  3. Copy setup from the existing tests, and use the project's own framework.
  4. Check each step, such as the position count, before moving on.
  5. Log values inside the contracts when the test does not behave as expected.
  6. Assert the exact failure, such as the custom error.
  7. Log the root cause to prove it, then refine toward a realistic scenario.

There is not much more to it. A PoC is working your idea through until it lives in a test file. To judge how serious the result is, see smart contract audit severity levels.

How Guardian Helps

In a Guardian smart contract audit, senior researchers attack protocol logic, integrations, privileged flows and economic assumptions. The review is exploit driven, with proof-of-concept depth and reproduction paths for what we find.

Frequently Asked Questions

What is a proof of concept in smart contract auditing?

It is a test case that reproduces a suspected vulnerability. It proves the issue is real and shows how critical it is, for example by making a liquidation revert or an accounting check fail.

Should I write PoCs in Foundry or Hardhat?

Use whatever the project's own test harness uses. In the GMX V2 example, the harness was in Hardhat, so the PoC was too. Rewriting a large harness in another framework for one PoC is not worth it.

How do I plan a PoC before writing code?

Write the steps as comments first. For example: open a position where the PnL token differs from the collateral token, build up fees until it is liquidatable, then execute the liquidation and check the result.

What should I do when my PoC does not behave as expected?

Log values inside the contracts, such as fees, collateral and the value you suspect is wrong. Fix the setup, such as fee factors or missing liquidity, until the test reaches the code path you are testing.

Sources

Get exploit-driven review with Guardian

Guardian researchers attack your protocol with proof-of-concept depth before attackers do.