- Total Prize Pool: $60,500 USDC
- HM awards: $42,500 USDC
- QA report awards: $5,000 USDC
- Gas report awards: $2,500 USDC
- Judge + presort awards: $10,000 USDC
- Scout awards: $500 USDC
- Join C4 Discord to register
- Submit findings using the C4 form
- Read our guidelines for more details
- Starts January 26, 2023 20:00 UTC
- Ends February 1, 2023 20:00 UTC
The C4audit output for the contest can be found here within an hour of contest opening.
Note for C4 wardens: Anything included in the C4udit output is considered a publicly known issue and is ineligible for awards.
Numoen Core is a protocol for the permissionless creation of option-like leverage tokens called Power Tokens that are enabled by the borrowing and lending of automated market maker (AMM) shares. The protocol implements a capped power invariant, introduced in the paper Replicating Monotonic Payoffs Without Oracles
, that allows lenders to provide two tokens to a pool of liquidity that always rebalances to a desired portfolio value via arbitrageurs. This portfolio value corresponds to a payoff that when inverted replicates the payoff of a power perpetual to some bound. Numoen Core achieves the power perpetual payoff through the AMM's LP shares that are lend out and used to mint Power Tokens. Borrowers provide collateral according to strict requirements and borrow the maximum amount of AMM shares from the pool. Funding rates are determined using the jump rate model with fixed parameters. The jump rate model is identical in structure to that of the Compound Protocol with changes made to the parameters so that it relates to the implied volitity of the LP share. Borrowers also pay interest by decreasing the overall size of their position and giving the collateral to lenders. Numoen allows for the permissionless creation of pairs using the factory model.
Numoen has docs but most information pertaining to the smart contracts are not relevant as the codebase documented is outdated. The newer version that is being audited through this contest is more robust and efficient. Therefore documentation on the smart contracts are most accurate here.
A new instance of a market is created using the factory. Token1 is the speculative token and token0 is the base token. The upper bound is the price at which the Power Token only holds the base token (token0) and the Power Token no longer has convexity. Token scales are meant to be decimals.
Liquidity providers provide liquidity to an AMM with a custom invariant. The invariant is documented in the function invariant
in Pair.sol
. The typical Mint
, Burn
, and Swap
functions are implemented. Swap is externally exposed so that accounts can swap between the underlying tokens of the pool with any trade that upholds the invariant. Callbacks are used to allow for flash swaps. Mint is not externally exposed and is called by a higher level function in Lendgine.sol
. Mint also uses callbacks to receive the tokens that are deposited which enables liquidity to be minted before supplying the underlying tokens. Mint checks that the deposited tokens in addition to the requested liquidity still satisfies the invariant or else reverts. Burn removes liquidity and transfers the underlying tokens to the recipient, while performing an extra, potentially unnecessary, check that the invariant is satisfied after the outputs are removed.
Liquidity positions are recorded with a size, tokensOwed, and rewardPerPositionPaid in Position.sol
. This is the same algorithm used by Synthetix StakingRewards.sol
. Size is a different unit than shares of the AMM because size accounts for the dilution that liquidity providers undergo, explained further in the interest section. Liquidity is provided through the deposit
function in Lendgine.sol
, which calculates the size of the liquidity, updates the position struct, and calles the underlying mint
function in Pair.sol
. Withdraw performs the opposite function, calculating how many shares of the AMM are proportional to the size of the position being withdrawn, updating the position struct, and calls the underlying burn
function in Pair.sol
.
Power Tokens are created by using token1 as collateral to borrow LP shares. Our invariant has the special property that underlying composition can be entirely token1 without needing an infinite amount of token0. This is similar to a bounded UniswapV3 position but dissimilar from UniswapV2. This means that we can determine an amount of token1 such that the value of that amount is greater than the value a LP share, no matter the exchange rate of the two underlying tokens. Thus, under collateralization is not possible with the correct amount of collateral. The mint
function determines how many LP shares are to be borrowed for the specified amount of collateral and then calls the underlying burn
function in Pair.sol
to remove the borrowed liquidity. The collateral is passed in through a callback function, which allows for liquidity to be optimistically borrowed, then paid for. Again, the size of the position is not directly proportional to the amount of liquidity being borrowed, so the amount of shares that a minter receives must be calculated. Power Tokens are minted as an ERC20 representing collateral in token1 and debt in LP shares. Power Tokens can be burned by transferring them to the Lendgine.sol
contract first, calculating the amount of liquidity owed, then calling the mint
function in Pair.sol
to payback the LP share debt owed and unlock the collateral of the position.
The jump rate model is used to determine the interest rate. Interest is accrued from Power Token holders to liquidity providers in the form of token1. When interest is accrued, the amount of LP shares and speculative tokens that should be removed from Power Token holders is determined. The collateral and debt of the options holders are decreased simultaneously. The debt of Power Token holders is forgiven, meaning that liquidity providers are not expecting to be repaid. This is why the size of a liquidity position is not equivalent to the LP shares that originally were deposited. To makeup for slowly decreasing amount of LP shares, liquidity providers are given the collateral removed from the Power Token holders.
In other terms, liquidity providers are slowly exchanging their liquidity for the collateral of Power Token holders. A Power Token position is gradually worth less and less because it represents a claim to a smaller pool of collateral and debt. A liquidity provider position is gradually worth less and less because it represents a claim to a smaller pool of AMM shares but this is made up because over time it is rewarded with token1 from the collateral of Power Token holders.
There is a special case when all liquidity currently borrowed is accrued at once. As long as liquidity is accrued somewhat frequently this should not happen. When this does happen, all Power Token positions are worth nothing and all LP positions are worth only the token1 that is owed to it. There are special checks in the mint
and deposit
function in Lendgine.sol
that disable opening new Power Tokens or LP positions because the amount to be rewards is not able to be determined. The market is effectively done at this point in time and would require a redeployment to be restarted from scratch.
The LiquidityManager
contract provides some helpers to aid with entering, exiting, and managing a LP position in Numoen. This adds checks for stale transactions, slippage, handling permit functions and native tokens.
The LendgineRouter
contract provides help when entering or exiting an option position. Checks for staleness, slippage, handling permit functions, and native tokens are included. This can also perform the leveraging and deleveraging of Power Token positions with the help of external liquidity pools such as UniswapV2 style pools and UniswapV3 style pools. This is somewhat similar to looping through compound while trading the borrowed token for collateral and then borrowing more. mint
takes the borrowed liquidity and transfers it entirely into token1 for collateral. A borrowed amount can be passed in such that liquidity can be optimistically borrowed for more collateral than the option depositor has at the moment, the underlying liquidity is then swapped entirely for token1 to use as collateral in combination with collateral from the user. burn
optimistically mints a liquidity position and repays debt, then uses the unlocked collateral to come up with the underlying for the liquidity position that was minted.
The following directories and implementations are considered in-scope for this audit.
For the Protocol Implementation, here's a brief description of each file.
File | SLOC | Description | Libraries |
---|---|---|---|
Contracts (4) | |||
src/core/Factory.sol ๐งฎ | 50 | Deploys lendgine markets | |
src/core/Lendgine.sol | 165 | Lending and borrowing of AMM shares | |
src/periphery/LiquidityManager.sol ๐ฐ | 168 | Aids with entry, exit, and management of liquidity positions | |
src/periphery/LendgineRouter.sol ๐ฐ | 206 | Aids with entry and exit of options positions | |
Abstracts (5) | |||
src/core/ImmutableState.sol | 19 | Immutables | |
src/core/JumpRate.sol | 34 | Interest rate curve | |
src/periphery/Payment.sol ๐ฐ | 42 | Functions to ease deposit and withdrawal of ETH | |
src/periphery/SwapHelper.sol | 72 | Facilitates swapping on external liquidity sources | |
src/core/Pair.sol | 81 | Implements AMM with Capped Power Invariant | |
Libraries (6) | |||
src/core/libraries/PositionMath.sol | 10 | Math for liquidity positions | |
src/libraries/Balance.sol ๐งฎ | 10 | Reads token balances | |
src/libraries/SafeCast.sol | 10 | Cast Solidity types | |
src/periphery/libraries/LendgineAddress.sol ๐งฎ | 32 | Computes Numoen Lendgine addresses | |
src/core/libraries/Position.sol | 62 | Liquidity position handler | |
src/periphery/UniswapV2/libraries/UniswapV2Library.sol ๐งฎ | 70 | Modified V2 Library for Solidity 0.8 | |
Total (over 15 files): | 1031 |
- If you have a public code repo, please share it here: N/A
- How many contracts are in scope?: 31
- Total SLoC for these contracts?: 1,014
- How many external imports are there?: 2
- How many separate interfaces and struct definitions are there for the contracts within scope?: 12
- Does most of your code generally use composition or inheritance?: Inheritance
- How many external calls?: 2
- What is the overall line coverage percentage provided by your tests?: 0
- Is there a need to understand a separate part of the codebase / get context in order to audit this part of the protocol?: No
- Please describe required context: N/A
- Does it use an oracle?: No
- Does the token conform to the ERC20 standard?: The option perps conform to the ERC-20 standard. But we have no governance token.
- Are there any novel or unique curve logic or mathematical models?: We use the "capped power" invariant from the Replicating Monotonic Payoffs paper: k = x โ (p_0 + (-1/2) * y)^2
- Does it use a timelock function?: No
- Is it an NFT?: No
- Does it have an AMM?: Yes it's a fully autonomous AMM.
- Is it a fork of a popular project?: No
- Does it use rollups?: No
- Is it multi-chain?: No
- Does it use a side-chain?: No
Numoen runs on Foundry. If you don't have it installed, follow the installation instructions here.
To install contract dependencies, run:
forge install
yarn
Some tests are reliant upon a fork of goerli. Add the goerli RPC to a .env file. See .env.example for an example. To run tests, run:
forge test --gas-report
The following test currently fails and has no workaround:
Encountered 1 failing test in src/core/Lendgine.sol:Lendgine
[FAIL. Reason: EvmError: Revert] setUp() (gas: 0)
Encountered a total of 1 failing tests, 103 tests succeeded
export RPC_URL_GOERLI="<your-goerli-rpc-url-goes-here>" && rm -Rf 2023-01-numoen || true && git clone https://github.com/code-423n4/2023-01-numoen.git -j8 --recurse-submodules && cd 2023-01-numoen && echo "RPC_URL_GOERLI=$RPC_URL_GOERLI" > .env && forge install && yarn && forge test --gas-report