GMX V2 Explained: Inside the Synthetics Exchange
Quick answer. GMX V2 is a synthetic assets perpetuals exchange: traders go long or short on any asset with a price feed, paid out from each market's backing tokens. Every action is a request a keeper executes later.
One of the best ways to find more critical bugs is simply to understand more systems. Knowing how AMMs, lending protocols and synthetics exchanges are usually built gets you through an audit's context-gaining phase faster.
That phase is the most time-intensive part of an audit. Familiar patterns can even hand you easy findings and strong leads right away.
This is an early 2023 look at GMX V2, a synthetic assets perpetuals exchange and one of the largest Solidity codebases out there, at easily 12,000 to 13,000 lines.
The first half follows the kickoff walkthrough Guardian gave its own team for the GMX V2 review: the design, goals and main code paths.
The second half is a live first pass over withdrawals, made during the GMX V2 contest on Sherlock.
The code described is the early 2023 version, so details may have changed since.
What Is GMX?
GMX is a perpetuals exchange. With just your wallet and crypto assets, you can open a long or a short on a token with no KYC.
You can close it whenever you like and realize your profit or loss. You can also set orders that act for you:
- Take profit. Long ether at $1,000 and exit automatically at $1,500, without logging on to close it.
- Stop loss. Exit that long automatically if ether falls to around $800.
The big addition in V2 is that it is a synthetic assets exchange. In GMX's earlier GLP model, which V2 made outdated, you could only trade assets that were provided as collateral.
In V2 you can trade really any asset that has a price feed. Long gold or short Tesla onchain, as long as a price feed exists.
How Traders Get Paid
How do you pay someone out for gold appreciating when there is no gold onchain? Through backing tokens.
Each market is a smart contract that holds a backing long token and a backing short token. The asset being traded is the index token.
Take a market for trading Solana. There is no Solana on Arbitrum, so Solana is the synthetic index token.
- The long token pays out profitable longs. It should generally track the index token's price, so wrapped ether is a decent choice: when Solana goes up, ether probably does too.
- The short token pays out profitable shorts. It is usually a stablecoin such as USDC, so shorts can still be paid when prices fall.
What if Solana rises faster than ether? That is an existential risk to the system. GMX has solutions for it, but they are a deeper detail of the design.
A leveraged position, start to finish
Say you put down $100 of collateral, in either backing token. You choose ether, and open a $1,000 Solana long at 10x leverage.
If Solana falls far enough that the position loses $98, only $2 of collateral is left to back it.
The code has a leverage threshold. Go past it and you are liquidated: your collateral is taken and given to the pool.
The depositors who supply the backing tokens earn the profit from your liquidation.
Two-Step Execution
The entry point is the ExchangeRouter. Users create deposits there to provide backing tokens. That is liquidity only loosely: depositors back the positions traders open.
Notice the function is not called deposit. It is createDeposit, because the deposit does not happen right away.
- You call
createDepositwith, say, 100 USDC as backing short token. - Your USDC goes to a temporary holding contract, the
DepositVault, not to the market. - A deposit object is stored, recording you as creator, how much you deposited and more.
- A keeper calls
executeDepositon theDepositHandlerwith the deposit's key. - The keeper's transaction moves the funds from the vault into the market and mints you a market token.
The market token is an ERC-20, like an optimizer vault token. It says what share of the market's backing tokens you own.
Withdrawals and orders work the same way: createWithdrawal and createOrder make a request, and a keeper executes it later.
Why two steps
The split exists to avoid a lot of front-running and MEV-style attacks on the exchange and its users. You do not know the order in which deposits and orders will be executed.
Front-running is a really rough attack vector to get around for an exchange like this. For more on it, see front-running smart contracts.
Walkthrough: Deposits
From the ExchangeRouter, createDeposit goes to the DepositHandler. The handler runs a feature validation, so the feature can be switched off, then calls a library with the logic.
The library reads how many long and short tokens just arrived in the DepositVault, sent in through a multicall on the router. It handles the execution fee and creates the deposit.
The stored deposit carries the whole request:
- The owner, and an optional callback contract. Everything has one, which is another way to make bad things happen in the system.
- The initial long and short tokens, and a swap path to swap them into the tokens you want deposited.
- The initial amounts, the current block number and the execution fee.
- A flag for unwrapping the native token. Why would a deposit need that? Leave an audit tag and move on rather than getting stuck.
The deposit gets a key and is stored, where the keeper can reach it later.
Executing the deposit
Only the keeper can call executeDeposit. It is wrapped in a try/catch, which is worth digging into later in the audit.
- Set prices and build the parameters.
- Validate that the prices the keeper sent are valid for when the deposit was submitted. A keeper must not execute you with crazy prices from a thousand blocks ago.
- Fetch the market and its prices, and run more validation.
- Run any swaps: deposit DAI, for example, and swap it to USDC first for a USDC market.
- Compute the USD deposited and the price impact.
- Send the tokens to the market, mint the market tokens, remove the deposit from storage, emit events, call the callback and pay the execution fee.
Price impact
GMX wants balanced markets, so that longs and shorts can be opened at equal magnitude.
If a market holds lots of USDC but little ether, depositing ether earns a small boost: positive price impact. Depositing more USDC into that market costs a little: negative price impact.
Pool accounting
A market contract holds several kinds of tokens at once:
- Long and short tokens from deposits (the pool amount).
- Collateral from traders opening positions.
- An amount set aside to pay positive price impact, the impact pool.
So balanceOf on the market cannot tell you how much was deposited. It returns everything combined.
Instead, a DataStore contract holds much of the system's storage, including the breakdown. A market holding 100 wrapped ether might split into a pool amount of 90, an impact pool of 2 and collateral of 8.
During a deposit, applyDeltaToPoolAmount moves any positive impact from the impact pool to the depositor's credit. It then adds the deposited amount, after fees, to the pool amount.
Gain Context First
In a codebase this size, minimize every function and look at the overall structure before getting stuck on any individual line. Getting stuck early kills the speed at which you gain context.
Context is everything. Once you have it, you can understand any line in a fraction of the time.
From there it is downhill. A system this large gives attackers many knobs, and almost everything in it is manipulatable enough to turn critical.
Walkthrough: Orders
createOrder on the ExchangeRouter goes to the OrderHandler. Only the controller, which is the router, can call it, and again a feature switch can turn it off.
The library records the collateral transferred into the vault, builds an order object, gets its key and stores it in the order store for the keeper. The user's transaction ends there.
createOrder and executeOrder are the two most important functions on the handler.
Execution can be switched off per order type. The library sets prices for the order, then calls processOrder.
The three order types
- Increase. Opens a position or adds to it. Use it to increase size, or to add collateral when you are losing money so you are not liquidated.
- Decrease. Reduces size or leverage, takes collateral out, or closes the position. This is how you take profit or realize a loss.
- Swap. Swaps using the deposited assets, which treats deposits more like liquidity. Swapping across markets changes their balances, which adds a whole other layer of attack vectors.
Executing an increase order
Like deposits, an order can swap its initial token into the collateral token: DAI to ether for the long side, or to USDC for the short side.
That swap is another knob for attackers, since it executes right before the order does.
Next comes a position key. If the position is new, it is populated with the market, the collateral token, whether it is long or short, and the owner.
After the increase logic runs, the order is removed from the order store so it cannot execute a second time.
If there is any way for an order to still exist in the store after this, that is automatically a critical vulnerability. You could double execute your order and be credited twice.
Inside increasePosition
- Process collateral. Tell the market it received collateral and increment the collateral accounting. The pool amount is untouched, since nobody is depositing.
- Set the collateral amount on the position, then get the execution price.
- Size delta in tokens. How much the position grew in index tokens, say 20 Solana.
- Size in USD and size in tokens. A $1,000 position records $1,000 in USD and its size in Solana.
Those two fields give you PnL. Multiply the size in tokens by Solana's current price and compare it to the size in USD.
If the result is $1,200, you are $200 in profit; if it is below $1,000, you are at a loss. The same numbers show whether the loss is too big for the collateral, and the position must be liquidated.
The position is then written to storage, and open interest is updated. If you hold a $1,000 position and Bob opens $2,000, the market's open interest is $3,000.
Finally come the checks:
- A position must not be liquidatable at the moment it is opened or increased.
- Reserves validation confirms the backing tokens can pay out the open interest if it profits.
Referral handling and events close it out.
First Pass: Withdrawals
This pass covers code read for the first time, after dozens of hours on the rest of this commit and hundreds on earlier V2 versions.
The aim is to gain context as fast as possible, leaving audit tags on red flags without chasing each one.
Start where users enter, the ExchangeRouter, and follow the path. There are three withdrawal functions: createWithdrawal, cancelWithdrawal and simulateExecuteWithdrawal.
Jump to definitions constantly. In our experience IntelliJ indexes Solidity better than editors like VS Code.
The WithdrawalHandler validates that withdrawals are enabled, then calls a library. A WithdrawalVault presumably holds funds between creation and execution.
Leads on the handler
- Cancel can revert.
cancelWithdrawalchecks that the withdrawal's account is not the zero address, and reverts if it is. Called after the withdrawal is removed, the cancel would revert instead of canceling. - A stuck withdrawal is a timing tool. If the transaction reverts, the keeper postpones execution. An attacker who controls when a withdrawal goes through might create one and wait for price to move in their favor: a risk-free trade.
- The simulate functions. They appear across the contracts, and there is at least one finding related to them.
- New block number ranges. Withdrawals take a range of oracle block numbers, which looks new in this commit. Pay special attention.
- Spoofed error selectors. The handler reads an error selector from the revert reason. Could a malicious token make its transfer revert with a selector that matches an empty price error, and trigger the same postponement?
Leads on creation
createWithdrawal records the transfer in and validates the execution fee in wrapped native token. Excess fees are presumably sent back or credited.
- Gas estimate versus gas used. Can a user make the keeper's actual gas far exceed the estimated gas limit? That could mean a gas attack, or worse, the user controlling the transaction's execution.
Leads on execution
executeWithdrawal is global non-reentrant, callable only by the order keeper, and populates prices with lists of minimum and maximum oracle block numbers.
- Check every params struct. With so many structs passed around, there is a non-zero chance one has the wrong address in the wrong spot.
- Unreachable checks. Creation already rejects a zero market token amount, yet execution checks again. Withdrawals cannot be updated (only orders can), so it may be a precaution. Note it anyway.
- Callbacks before validation. The updated-at block is validated against the range. Before callbacks for withdrawals used to exist; they are gone now, which is good.
- The arbitrage they would have enabled. A before callback could move market tokens to another address, failing the withdrawal until the attacker wants it. Combined with dodging cancellation, that is a risk-free arbitrage withdrawal. Low confidence, but worth a note.
Learn each function's geometry: validation on top, the real execution in the middle, then events, callback and fee. When you return, you can spend your head space on the lower-level details.
The removal that comes too early
Next, ask: what if this could be forced to fail? Find where the withdrawal is removed from the store.
It is removed early, and everything after can revert. On a revert, the handler's error path tries to cancel the withdrawal, which is meant to stop attackers from failing a withdrawal until it suits them.
But the cancel first fetches the withdrawal, and it is already removed. The empty check reverts, so the whole transaction reverts and the withdrawal never gets canceled.
The withdrawal stays in the store. The keeper, an automated script, will execute it whenever it can succeed.
Swaps run after the removal, which gives an attacker a good opportunity to make the transaction fail. This looks like a critical. The lead is to PoC it and see exactly what the attacker can do.
That instinct comes from pattern recognition. Bugs found elsewhere in GMX's two-step execution point to the same weakness here.
Back to the top of the function
- Market validation.
getEnabledMarketfetches the market from the store and checks it is a real, enabled market. - Gas findings. Tag them and report them if valid and significant. For unlaunched protocols that will hold hundreds of millions, the focus is preventing critical exploits.
- Uninitialized cache fields. Big cache structs invite a field that is never set. If the long token output amount stays at its uint256 default, a user could withdraw and get nothing: an easy High.
- Fees charged twice. Long and short token fees are computed separately and added to claimable fees for the right tokens. Could a swap path charge them twice?
- The accounting. A mistake here is almost always High or Critical. Chart how the fee amount for the pool is built, and check everything is counted, and counted only once.
Small accounting errors yield insolvent markets. Traders cannot exit, LPs cannot withdraw, or someone withdraws or profits more than they should. For more, see DeFi accounting vulnerabilities.
Checklist for Two-Step Systems
- Can any request be executed twice, or stay in its store after execution?
- Can a user make execution revert, and does the cancel path survive that revert?
- Can a user control when a request executes: through callbacks, swaps, reverting tokens or gas?
- Are keeper prices validated against when the request was created?
- Does every params struct and cache field get the right value?
- Is every fee and token counted once, in the right bucket?
How Guardian Helps
Guardian has reviewed every smart contract update GMX has shipped since October 2022. Before V2 launched, GMX engaged Guardian eight times between October 2022 and September 2023. Read the GMX client story.
Vanguard brings the same depth to your protocol: two independent teams, invariant fuzzing and review of every update after launch.
Frequently Asked Questions
What is GMX V2?
GMX V2 is a synthetic assets perpetuals exchange. Traders open leveraged longs and shorts on any asset with a price feed, and each market's backing long and short tokens, supplied by depositors, pay out profitable traders.
Why does GMX V2 use keepers to execute requests?
Deposits, withdrawals and orders are created as requests and executed by a keeper in a second transaction. Users cannot know the execution order, which limits front-running and MEV-style attacks on the exchange.
What is the index token in a GMX market?
The index token is the asset being traded. It does not need to exist onchain: a Solana market on Arbitrum can use wrapped ether as the backing long token and USDC as the backing short token.
What should auditors look for in two-step execution?
Look for requests that can execute twice or survive in the store, and executions a user can force to revert. Then check cancel paths, keeper price validation, uninitialized struct fields, and fees counted twice.
Sources
Get your keeper flows reviewed by Guardian
Guardian puts two independent teams on your codebase and keeps reviewing every update after launch.