Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ For each external function, how does it protect against re-entrancy?
### Sandwich Attack
An attacker manipulates AMM prices before and after our swap to capture part of the value of our swap.
- The primary mitigation is a slippage limit, which limits how much slippage we will accept on each trade. This doesn't prevent the attack, but does limit how much value can be extracted per trade.
- `rebalance` enforces this limit using the pool's native `sqrtPriceLimitX96`: each rebalance swap carries a marginal-price bound derived from the yield oracle discounted by `maxSlippageBps`. The pool fills the swap only while its marginal price stays within the bound, then stops. A swap too large to reach the re-entry target within tolerance is a **partial fill** rather than a revert. Successive rebalances are expected to fill more as the gap (and price impact) shrinks, converging over several calls.
- The bound is on the pool's **marginal price** relative to the oracle, i.e. on price impact. The pool's fixed LP fee is a separate, known cost taken on the input and is not part of this bound.
- Flow as the underlying platform provides some protection. There is no system akin to [MEV-Boost](https://github.com/flashbots/mev-boost), which systematizes MEV extraction. No individual node in Flow can deterministically dictate transaction ordering. Attackers need to send many transactions, hope some are placed in the desired order, and be able to revert operations on those that are not in the desired order. Still possible, but more complex and expensive.

If an attacker is able to invoke a function which performs a swap (that isn't swapping their funds), then the sandwich attack becomes much more dangerous (eg. a permissionless `rebalance` function).
Expand Down
3 changes: 2 additions & 1 deletion docs/vault-rebalancer.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ One Cadence resource per EVM target, owned by an admin account. Stored at a dete

| Failure | Observable | Recovery |
| :--- | :--- | :--- |
| EVM call fails, transient (slippage, momentary state, out-of-gas) | Tick event with EVM error code | Next tick retries automatically; persistent OOG requires admin to raise the EVM gas limit |
| EVM call fails, transient (momentary state, out-of-gas) | Tick event with EVM error code | Next tick retries automatically; persistent OOG requires admin to raise the EVM gas limit |
| Slippage: full target swap would breach the price bound | Tick succeeds; `Rebalanced`/`VaultState` show a smaller-than-target move (or no move) | None needed, subsequent ticks will invoke rebalance again until the target swap occurs |
| EVM call fails, sustained (role revoked EVM-side, persistent Solidity-side condition) | Tick event repeats with non-zero error code N ticks in a row | Admin addresses EVM-side condition (restore role, resolve Solidity state) |
| Fee vault depletion / storage-min breach (Cadence scheduling fees) | No event; absence of `Ticked` triggers the liveness alert | Admin tops up; signs tx to re-invoke self-reschedule |
| COA FLOW depletion (EVM-side gas) | Tick events repeat with non-zero EVM error code; off-chain balance script catches drift earlier | Anyone can send FLOW to the COA (permissionless, from either Cadence or EVM) |
Expand Down
1 change: 1 addition & 0 deletions solidity/script/DeployVault.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ contract DeployVault is ConfiguredScript {
marketLltv: c.marketLltv,
feeYieldDebt: c.feeYieldDebt,
feeAssetDebt: c.feeAssetDebt,
yieldDebtPool: c.yieldDebtPool,
healthFactorMin: c.healthFactorMin,
healthFactorMax: c.healthFactorMax,
healthFactorMinTarget: c.healthFactorMinTarget,
Expand Down
189 changes: 148 additions & 41 deletions solidity/src/FCMVault.sol

Large diffs are not rendered by default.

51 changes: 33 additions & 18 deletions solidity/src/libraries/SwapLib.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ library SwapLib {
/// `fee` is the FlowSwap V3 fee tier (e.g. 100 / 500 / 3000).
/// Setting `amountOutMinimum = 0` is intentional for legs whose
/// downstream accounting already enforces fairness (e.g. redeem
/// scales by realized output); use `swapExactInMin` for legs
/// that need an explicit floor.
/// scales by realized output); use `swapExactInToLimit` for legs
/// that need an explicit price-impact bound.
/// @param tokenIn Token being sold.
/// @param tokenOut Token being bought.
/// @param fee Pool fee tier.
Expand All @@ -42,24 +42,39 @@ library SwapLib {
);
}

/// @notice Same as `swapExactIn` but reverts in the router if the
/// realized output is below `amountOutMinimum`.
/// @dev Used by legs that need an explicit slippage cap derived from
/// an oracle price and a price-impact tolerance.
/// @param tokenIn Token being sold.
/// @param tokenOut Token being bought.
/// @param fee Pool fee tier.
/// @param amountIn Amount of `tokenIn` to sell.
/// @param amountOutMinimum Minimum acceptable `tokenOut` output; the
/// router reverts if the realized amount is
/// strictly less than this value.
/// @return amountOut Realized amount of `tokenOut` received.
function swapExactInMin(
/// @notice Swap up to `amountIn` of `tokenIn` for `tokenOut`, stopping early
/// if the pool's marginal price reaches `sqrtPriceLimitX96`. The
/// pool fills the swap natively up to that price bound and then
/// stops without reverting, so a swap too large to complete within
/// the bound is a *partial fill* rather than a revert.
/// @dev This is the canonical Uniswap V3 mechanism for a best-effort
/// swap under a price bound: the pool's swap loop runs while input
/// remains AND the marginal price has not reached the limit, so the
/// marginal (and therefore average) execution price never crosses
/// the limit. `amountOutMinimum` is left at 0 — protection comes
/// entirely from the price limit, and a non-zero minimum would
/// revert a legitimate partial fill.
///
/// IMPORTANT: on a partial fill the router consumes LESS than
/// `amountIn` and leaves the unspent `tokenIn` with the caller — the
/// caller must account for the remainder (the vault repays it).
/// `sqrtPriceLimitX96` MUST be on the correct side of the current
/// pool price (below it for a 0->1 swap, above it for 1->0),
/// otherwise the pool reverts `SPL`; callers check the live price
/// first. Caller MUST have approved `SWAP_ROUTER` for `tokenIn`.
/// @param tokenIn Token being sold.
/// @param tokenOut Token being bought.
/// @param fee Pool fee tier.
/// @param amountIn Maximum amount of `tokenIn` to sell.
/// @param sqrtPriceLimitX96 Q64.96 marginal-price bound the swap will not
/// cross; the fill stops here if reached.
/// @return amountOut Realized amount of `tokenOut` received.
function swapExactInToLimit(
address tokenIn,
address tokenOut,
uint24 fee,
uint256 amountIn,
uint256 amountOutMinimum
uint160 sqrtPriceLimitX96
) internal returns (uint256 amountOut) {
return SWAP_ROUTER.exactInputSingle(
ISwapRouter.ExactInputSingleParams({
Expand All @@ -68,8 +83,8 @@ library SwapLib {
fee: fee,
recipient: address(this),
amountIn: amountIn,
amountOutMinimum: amountOutMinimum,
sqrtPriceLimitX96: 0
amountOutMinimum: 0,
sqrtPriceLimitX96: sqrtPriceLimitX96
})
);
}
Expand Down
Loading