Prerequisites: Guide 4: Trade-offs and Risks Next: Guide 6: Future Improvements
Status: Proof of concept. Not audited and not deployed.
This guide describes the code as it is. Earlier versions of this file contained design-time sketches
(role-based access control, a receiveLoss vault function, WAD-based PnL); none of that was built, and it
has been replaced by the actual structures.
- Tech Stack
- Data Structures
- Interfaces
- Code Patterns
- Numerical Precision and Rounding
- Before Any Deployment
| Component | Version or setting | Source |
|---|---|---|
| Solidity | 0.8.24, fixed by pragma solidity 0.8.24; in every file |
src/, test/, script/ |
| Foundry | forge 1.7.1, pinned in CI (version: v1.7.1) |
.github/workflows/test.yml |
| Solady | v0.1.26 (git submodule) | foundry.lock, .gitmodules |
| forge-std | v1.14.0 (git submodule) | foundry.lock, .gitmodules |
| Pyth SDK | @pythnetwork/pyth-sdk-solidity ^4.3.1 (npm), remapped from node_modules/; npm ci is required before forge build |
package.json, foundry.toml |
| Formatter | forge fmt, line_length = 160 |
foundry.toml [fmt] |
foundry.toml sets ffi = false, the optimizer with 200 runs and evm_version = "cancun" (the engine and
the vault keep their ETH refund baseline in transient storage), excludes test/gas from the default profile
(a gas profile runs only the gas benchmarks), and has a coverage profile that lifts the contract size
limit for coverage builds only. It does not set solc or via_ir.
The Pyth SDK stays on npm because there is no tagged Solidity SDK repository to use as a submodule:
pyth-network/pyth-sdk-solidityis archived (last commit 2025-05-06, "Add deprecation notices"); its newest tag,v2.2.0, has noPythUtils.sol, whichPythChainlinkOracleuses (gh api repos/pyth-network/pyth-sdk-solidity --jq .archived,gh api "repos/pyth-network/pyth-sdk-solidity/contents/PythUtils.sol?ref=v2.2.0"returns 404).- The SDK now lives in the
pyth-network/pyth-crosschainmonorepo, none of whose 1,037 tags is a Solidity SDK tag (gh api --paginate 'repos/pyth-network/pyth-crosschain/git/matching-refs/tags/' --jq '.[].ref' | grep -ciE "solidity"prints 0).
Solady modules used: ERC4626, ERC20, Ownable, ReentrancyGuard, SafeTransferLib, SafeCastLib.
OpenZeppelin is not a dependency.
struct Trade {
address user; // 20 bytes -┐
bool isLong; // 1 byte │
uint16 pairIndex; // 2 bytes │ Slot 0 (31/32)
uint16 leverage; // 2 bytes │
uint48 timestamp; // 6 bytes -┘
uint32 index; // 4 bytes -┐
uint32 userIndex; // 4 bytes │ Slot 1 (full)
uint64 collateral; // 8 bytes │
uint128 openPrice; // 16 bytes -┘
uint128 tp; // 16 bytes -┐ Slot 2 (full)
uint128 sl; // 16 bytes -┘
}collateralis the stored collateral after the open fee (USDC, 6 decimals).openPrice,tp,slare 18-decimal prices;openPriceincludes the open spread.user == address(0)marks a deleted or nonexistent trade. Trade IDs come from auint32counter and are never reused.userIndexis the trade's position in its user's list, sodeleteTraderemoves it in constant time.- The entry funding index (of the trade's side) is stored separately in
mapping(uint256 => int256) _tradeFundingIndex.
struct Pair {
string name; // 32 bytes -── Slot 0 (pointer)
uint128 maxOI; // 16 bytes -┐
uint16 maxLeverage; // 2 bytes │ Slot 1 (19/32)
bool isActive; // 1 byte -┘
}struct PairFeed {
bytes32 pythFeedId; // 32 bytes -── Slot 0 (full)
address chainlinkFeed; // 20 bytes -┐
uint32 chainlinkHeartbeat; // 4 bytes │ Slot 1 (25/32)
bool active; // 1 byte -┘
}struct SideTotals {
uint128 collateral; // 16 bytes -┐ Slot 0 (full)
uint128 quantity; // 16 bytes -┘
}_longTotals and _shortTotals map a pair index to its totals; the side size is the existing open
interest mapping. quantity is the sum of size_wad x 1e18 / openPrice over the side's positions, rounded
up per long and down per short, in WAD asset units. _openTradeCount and _positionsNonce (both uint32)
share slot 0 with tradingEngine and _tradeCounter; every storeTrade and deleteTrade increments the
nonce.
struct PnlSnapshot {
int128 netPnl; // 16 bytes -┐
uint48 timestamp; // 6 bytes │ Slot 0 (26/32)
uint32 nonce; // 4 bytes -┘
}netPnl is USDC (6 decimals), positive when traders are in profit. maxPnlSnapshotAge (uint32) shares a
slot with tradingEngine, pauseFlags (uint8) and _withdrawPausedSince (uint48); _withdrawPausedTotal
(uint64) shares a slot with solvencyManager.
struct WithdrawalRequest {
uint256 shares; // 32 bytes -── Slot 0 (full)
uint128 requestEpoch; // 16 bytes -┐ Slot 1 (full)
uint128 withdrawPausedAtRequest; // 16 bytes -┘
}One request per address. The requested shares are held by the vault (escrow); a new request returns the
previous escrow and replaces the request. withdrawPausedAtRequest is the number of seconds the vault had
spent under PAUSE_WITHDRAW when the request was made; the time paused since then extends the request's
expiry (Guide 8, section 5).
struct BondPosition {
uint128 totalSynth;
uint128 claimedSynth;
uint64 start;
uint64 end;
}A bonder can hold several positions (mapping(address => BondPosition[])).
interface IOracle {
function getPrice(uint256 pairIndex, bytes[] calldata priceData) external payable returns (uint128 price18, uint128 conf18);
function checkOpenAllowed() external view;
}checkOpenAllowed reverts when conditions outside the price do not allow new positions; openTrade calls it
(the sequencer grace period in PythChainlinkOracle). priceData is opaque update data for pull oracles. The caller pays any fee in msg.value and the oracle
refunds the surplus to msg.sender. TradingEngine stores the oracle as an immutable, so replacing it
requires a new engine deployment.
Minimal views and calls used by SolvencyManager and BondDepository: collateralizationRatio(),
collateralizationDeficit(), realisedCollateralizationRatio(), realisedCollateralizationDeficit(),
isPnlSnapshotFresh(), refreshPnlSnapshot(bytes[]) (payable), totalAssets(), balance(),
injectFunds(uint256), isActive(), activateBonding(uint256), closeBonding(). ISolvencyManager: checkAndActBeforeDeposit() and
bondingRoundOpenAfterCheck(), which the Vault calls on its deposit paths and in maxDeposit/maxMint.
mint(address to, uint256 amount), used by BondDepository.
Local copy of the Chainlink aggregator interface (latestRoundData, decimals).
function openTrade(uint16 pairIndex, bool isLong, uint64 collateral, uint16 leverage, uint128 expectedPrice, uint16 slippageBps, uint128 tp, uint128 sl, bytes[] calldata priceUpdate) external payable returns (uint32 tradeId);
function closeTrade(uint256 tradeId, uint128 expectedPrice, uint16 slippageBps, bytes[] calldata priceUpdate) external payable;
function liquidate(uint256 tradeId, bytes[] calldata priceUpdate) external payable;
function executeLimit(uint256 tradeId, bytes[] calldata priceUpdate) external payable;
function updateTp(uint256 tradeId, uint128 newTp, bytes[] calldata priceUpdate) external payable;
function updateSl(uint256 tradeId, uint128 newSl, bytes[] calldata priceUpdate) external payable;Slippage: abs(executionPrice - expectedPrice) * 10000 <= expectedPrice * slippageBps.
TP/SL: openTrade, updateTp and updateSl reject a TP or SL that is already crossed at the oracle
price; TradingStorage also checks it against the open price (long tp > openPrice, sl < openPrice;
short the reverse).
-
Custom errors with parameters, no revert strings, for example
error LeverageExceedsMax(uint16 leverage, uint16 maxLeverage);anderror MaxOpenInterestExceeded(uint256 newOI, uint128 maxOI);. -
Access control: Solady
Ownablein every contract, plus single-address gates implemented as a modifier that calls an internal check, for example:modifier onlyTradingEngine() { _requireTradingEngine(); _; } function _requireTradingEngine() internal view { if (msg.sender != tradingEngine) revert CallerNotTradingEngine(); }
Gates:
TradingStorage.onlyTradingEngine,Vault.sendPayout(inline check),SpreadManager.onlyKeeper,AssistantFund.onlySolvencyManager,BondDepository.onlySolvencyManager,SynthToken.onlyMinter. -
Pause:
TradingEngine(PAUSE_OPEN,PAUSE_SETTLE) andVault(PAUSE_DEPOSIT,PAUSE_WITHDRAW) each keep auint8 pauseFlagsset bysetPauseFlags;whenNotPaused(flag)calls_requireNotPaused(flag), which reverts withEnforcedPause(flag).TradingStoragehas no pause: the engine's flags decide when collateral moves. -
Reentrancy: Solady
ReentrancyGuardonTradingEngine.openTrade,closeTrade,liquidate,executeLimit,updateTp,updateSland onVault.deposit,mint,requestWithdrawal,executeWithdrawal,sendPayout. -
ETH refunds: the
refundsEthSurplusmodifier storesbalance - msg.valuein transient storage at entry (tstore, EVMcancun) and refundsbalance - baselineat the end of the call.TradingStoragehas none: all its mutating functions are restricted to the engine. -
Ordering: in the engine, the trade is deleted and OI reduced before USDC is transferred. The oracle and the funding-state update in
TradingStorageare called before that. -
Token transfers:
SafeTransferLib(safeTransfer,safeTransferFrom,safeTransferETH).
| Quantity | Decimals | Notes |
|---|---|---|
| USDC amounts (collateral, fees, PnL, payouts) | 6 | PnL is computed in USDC units directly |
| sUSDC shares | 18 | _decimalsOffset() = 12 |
| Prices (oracle, open, TP, SL) | 18 | Pyth normalised with PythUtils.convertToUint; Chainlink scaled by 10 ** (18 - decimals) |
| Open interest, position size for OI | 18 | collateral * leverage * 1e12 |
| Funding index | 18 (signed), one per side | FundingLib; funding owed is size x delta / 1e30 in USDC units |
| Volatility | 18 | 3% = 3e16 |
Open PnL quantity (SideTotals.quantity) |
18 | asset units, size_wad x 1e18 / openPrice |
PnL snapshot (PnlSnapshot.netPnl) |
6 (signed) | rounded toward plus infinity |
| Collateralization ratios (NAV and realised) | 18 | 1e18 = 100% |
| Percentages | BPS | 10,000 = 100% |
PnL (TradingEngine._calculatePnl):
uint256 size = uint256(_collateral) * uint256(_leverage);
if (_isLong) {
// Floor exitValue: larger loss for the trader, rounding favours the pool
uint256 exitValue = (uint256(_closePrice) * size) / uint256(_openPrice);
pnlUsdc = int256(exitValue) - int256(size);
} else {
// Ceil exitValue: larger loss for the trader, rounding favours the pool
uint256 num = uint256(_closePrice) * size;
uint256 exitValue = (num + uint256(_openPrice) - 1) / uint256(_openPrice);
pnlUsdc = int256(size) - int256(exitValue);
}Rounding directions for every formula are listed in Guide 2. Funding rounds up for payers and down for receivers, so it also favours the protocol, by at most 1 USDC unit per position.
State of the items usually checked before a deployment, as of this review:
| Item | State |
|---|---|
| Admin keys in a multisig, timelock on parameter changes | Not in code; single Ownable owner per contract |
| Pausable trading | Independent flags: engine PAUSE_OPEN, PAUSE_SETTLE (closes, TP/SL and liquidations together, funding frozen); vault PAUSE_DEPOSIT, PAUSE_WITHDRAW (expiry clock stopped); playbook in Guide 8 |
| Reentrancy guards | See section 4 |
| Oracle validation | Price age (5 s), future timestamp, confidence, Chainlink deviation and heartbeat, L2 sequencer uptime |
| Circuit breakers | Not implemented |
| Emergency withdrawal for LPs | Not implemented |
| Unit, fuzz, invariant, fork tests | Present; fork suite on Arbitrum One at a pinned block (test docs) |
| Invariant tying vault assets, open collateral and fees | Present (invariant_VaultBalanceMatchesModelledFlows); the NAV is checked against the balance and the snapshot (invariant_TotalAssetsIsBalanceMinusSnapshotLiability) and the snapshot against a brute-force valuation |
| Static analysis | Slither and Aderyn run; findings not triaged in this round |
| External audit | Not done |
| Bug bounty | None |
See also: