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
13 changes: 12 additions & 1 deletion src/ExecutionModule.sol
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,24 @@ contract ExecutionModule is IExecutionModule, Ownable {
uint256 shortfall = (amountWad - safeVal) * 10_250 / 10_000;
uint256 riskyVal = riskyLeg.value();
if (shortfall > riskyVal) shortfall = riskyVal;
if (shortfall > 0) _sellRisky(shortfall, 150);
// Best-effort (audit L4): if the risky sale can't clear its bound,
// still deliver the safe-leg portion below rather than reverting
// the whole redemption funding. Self-call so try/catch applies.
if (shortfall > 0) {
try this.sellRiskySelf(shortfall) {} catch {}
}
}
uint256 available = FixedPointMathLib.min(amountWad, safeLeg.value());
safeLeg.provide(available, vault);
emit AssetsFreed(available);
}

/// @dev Self-call entrypoint so `freeAssets` can try/catch the risky sale.
function sellRiskySelf(uint256 deltaWad) external {
if (msg.sender != address(this)) revert NotVault();
_sellRisky(deltaWad, 150);
}

// ---------- composition maintenance (keeper) ----------

/// @notice Move the risky leg's wstETH share toward its target, bounded
Expand Down
20 changes: 18 additions & 2 deletions src/PendlePTAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ contract PendlePTAdapter is IPTAdapter, Ownable {

address public manager; // SafeLegManager, set once
uint256 public maxSlippageBps = 50;
uint256 public approxWindowBps = 1000; // PT-buy search window above expected

uint256 internal constant YEAR = 365 days;

Expand Down Expand Up @@ -111,6 +112,20 @@ contract PendlePTAdapter is IPTAdapter, Ownable {
emit SlippageSet(bps);
}

/// @notice Width of the PT-buy binary-search window above the expected fill
/// (bps). Wider tolerates larger spot-vs-TWAP divergence before the
/// router search range reverts (audit M2). Bounded to keep the
/// search from overflowing router math.
function setApproxWindowBps(uint256 bps) external onlyOwner {
if (bps < 100 || bps > 5000) revert BadSlippage();
approxWindowBps = bps;
}

/// @notice Return un-deposited deposit asset to the manager (audit M2).
function reclaim(uint256 amount, address to) external onlyManager {
asset.safeTransfer(to, amount);
}

// ---------- IPTAdapter ----------

/// @notice PT position marked at the oracle TWAP rate, WAD asset terms.
Expand Down Expand Up @@ -204,8 +219,9 @@ contract PendlePTAdapter is IPTAdapter, Ownable {
swapData: SwapData(SwapType.NONE, address(0), "", false)
});
// oracle-anchored search range: the true fill lives near expectedPt,
// so a tight window converges fast and cannot overflow router math
ApproxParams memory approx = ApproxParams(minPtOut, expectedPt * 110 / 100, 0, 30, 1e14);
// so a bounded window converges fast and cannot overflow router math
uint256 guessMax = expectedPt * (10_000 + approxWindowBps) / 10_000;
ApproxParams memory approx = ApproxParams(minPtOut, guessMax, 0, 30, 1e14);
(netPtOut,,) = router.swapExactTokenForPt(address(this), market, minPtOut, approx, input, _emptyLimit());
emit Deposited(assets, netPtOut);
}
Expand Down
63 changes: 42 additions & 21 deletions src/SafeLegManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -108,33 +108,52 @@ contract SafeLegManager is ILeg, Ownable {
if (buf > target) {
toPtWad = buf - target;
uint256 assets = toPtWad / assetScale;
if (assets > 0) {
asset.safeTransfer(address(pt), assets);
pt.deposit(assets);
}
if (assets > 0) _buyPtBestEffort(assets);
}
emit Inflow(buf, buf - toPtWad, toPtWad);
}

/// @notice Deliver `amountWad` of deposit asset to `to`: buffer down to
/// its minimum band first, PT for the remainder.
/// @dev PT sale proceeds can be below amount requested (duration risk /
/// slippage); the shortfall reduces delivered assets, never reverts,
/// so the emergency path cannot be blocked by PT market conditions.
function provide(uint256 amountWad, address to) external onlyRouter returns (uint256 deliveredAssets) {
if (amountWad > value()) revert InsufficientValue();
/// @dev Move `assets` into PT, but never revert the caller if the Pendle
/// market is dislocated (audit M2): on failure the assets are pulled
/// back to the buffer. onInflow runs inside the permissionless
/// emergency rebalance, so a PT-buy revert must not unwind the
/// de-risk. Returns whether the buy succeeded.
function _buyPtBestEffort(uint256 assets) internal returns (bool) {
asset.safeTransfer(address(pt), assets);
try pt.deposit(assets) {
return true;
} catch {
pt.reclaim(assets, address(this));
return false;
}
}

/// @notice Deliver up to `amountWad` of deposit asset to `to`: buffer down
/// to its minimum band first, PT for the remainder. Best-effort and
/// never reverts on PT conditions (audit M1).
/// @dev A request that fits the deliverable buffer takes an oracle-free
/// fast path (no pt.value() read), so a Pendle-oracle outage cannot
/// block a buffer-only payout. When PT is needed, the withdraw is
/// wrapped: if the Pendle market cannot fill within its slippage
/// bound, the buffer portion is still delivered and the shortfall
/// simply reduces `deliveredAssets`, so the emergency de-risk and
/// redemption funding are never bricked by PT market conditions.
function provide(uint256 amountWad, address to) external onlyRouter returns (uint256 deliveredAssets) {
uint256 buf = bufferWad();
uint256 minBuf = _bandWad(bufferMinBps);
uint256 fromBuffer = buf > minBuf ? buf - minBuf : 0;
if (fromBuffer > amountWad) fromBuffer = amountWad;
// if PT cannot cover the remainder, dig into the protected buffer band

uint256 fromPt = amountWad - fromBuffer;
uint256 ptValue = pt.value();
if (fromPt > ptValue) {
uint256 extra = fromPt - ptValue;
fromPt = ptValue;
fromBuffer = fromBuffer + extra > buf ? buf : fromBuffer + extra;
if (fromPt > 0) {
// only now touch the PT/oracle path
uint256 ptValue = pt.value();
if (fromPt > ptValue) {
// PT cannot cover the remainder: dig into the protected band
uint256 extra = fromPt - ptValue;
fromPt = ptValue;
fromBuffer = fromBuffer + extra > buf ? buf : fromBuffer + extra;
}
}

if (fromBuffer > 0) {
Expand All @@ -143,7 +162,11 @@ contract SafeLegManager is ILeg, Ownable {
deliveredAssets = assets;
}
if (fromPt > 0) {
deliveredAssets += pt.withdraw(fromPt, to);
try pt.withdraw(fromPt, to) returns (uint256 got) {
deliveredAssets += got;
} catch {
// PT market could not fill within its bound; deliver buffer only
}
}
emit Provided(to, amountWad, fromBuffer, fromPt);
}
Expand All @@ -156,9 +179,7 @@ contract SafeLegManager is ILeg, Ownable {
if (buf > _bandWad(bufferMaxBps)) {
uint256 excess = buf - target;
uint256 assets = excess / assetScale;
if (assets > 0) {
asset.safeTransfer(address(pt), assets);
pt.deposit(assets);
if (assets > 0 && _buyPtBestEffort(assets)) {
emit BufferRebalanced(-int256(assets * assetScale));
}
} else if (buf < _bandWad(bufferMinBps)) {
Expand Down
5 changes: 5 additions & 0 deletions src/interfaces/IPTAdapter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ interface IPTAdapter {
/// Caller must have transferred the assets to the adapter first.
function deposit(uint256 assets) external;

/// @notice Return up to `amount` of un-deposited deposit asset held by the
/// adapter to `to` (the manager). Used to recover funds when a PT
/// buy is skipped/failed so nothing strands (audit M2).
function reclaim(uint256 amount, address to) external;

/// @notice Sell/redeem PT worth `amountWad` and send proceeds to `to`.
/// @return assetsOut deposit-asset units actually delivered
function withdraw(uint256 amountWad, address to) external returns (uint256 assetsOut);
Expand Down
16 changes: 16 additions & 0 deletions test/ExecutionLayer.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,22 @@ contract ExecutionLayerTest is Test {
assertEq(usdc.balanceOf(address(vault)), 40e6);
}

function test_l4_freeAssets_deliversSafePortionWhenRiskySaleReverts() public {
// safe leg holds 40 (buffer), risky leg holds WETH; ask for 60 so a
// risky shortfall sale is needed
usdc.mint(address(safeLeg), 40e6);
weth.mint(address(riskyLeg), 5e18); // 10,000 of ETH exposure
// both swap tiers disabled: the risky sale cannot clear
router.setTier(500, 0, true);
router.setTier(3000, 0, true);

vm.prank(address(vault));
exec.freeAssets(60e18); // must NOT revert

// the safe-leg portion was still delivered
assertEq(usdc.balanceOf(address(vault)), 40e6);
}

function test_composition_movesTowardTarget() public {
vm.prank(owner);
riskyLeg.setWstethTarget(2500); // 25%
Expand Down
64 changes: 60 additions & 4 deletions test/SafeLegManager.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,36 @@ contract MockPTAdapter is IPTAdapter {
haircutBps = bps;
}

bool public failDeposit;
bool public failWithdraw;
bool public failValue;

function setFailDeposit(bool v) external {
failDeposit = v;
}

function setFailWithdraw(bool v) external {
failWithdraw = v;
}

function setFailValue(bool v) external {
failValue = v;
}

function value() external view returns (uint256) {
require(!failValue, "oracle down");
return valueWad;
}

function deposit(uint256 assets) external {
require(!failDeposit, "PT market dislocated");
valueWad += assets * 1e12;
}

function reclaim(uint256 amount, address to) external {
usdc.safeTransfer(to, amount);
}

/// @dev The real PT accretes toward par; this mock does not. Scenario
/// tests inject that carry explicitly so maturity math is faithful.
function simulateYield(uint256 amountWad) external {
Expand All @@ -43,6 +65,7 @@ contract MockPTAdapter is IPTAdapter {
}

function withdraw(uint256 amountWad, address to) external returns (uint256 assetsOut) {
require(!failWithdraw, "PT slippage exceeded");
valueWad -= amountWad;
assetsOut = (amountWad * (10_000 - haircutBps) / 10_000) / 1e12;
usdc.safeTransfer(to, assetsOut);
Expand Down Expand Up @@ -146,11 +169,44 @@ contract SafeLegManagerTest is Test {
assertEq(out, 49.7e6);
}

function test_provide_revertsBeyondValue() public {
_inflow(100e6);
function test_provide_beyondValue_bestEffortNoRevert() public {
_inflow(100e6); // total value 100
// ask for more than the leg holds: delivers all available, never reverts
vm.prank(executor);
uint256 out = manager.provide(101e18, executor);
assertEq(out, 100e6);
assertEq(manager.value(), 0);
}

// ---------- M1/M2 regression: PT reverts never brick the flow ----------

function test_m1_ptWithdrawRevert_deliversBufferBestEffort() public {
_inflow(100e6); // buffer 30, pt 70
pt.setFailWithdraw(true); // PT sale reverts (can't meet its bound)
// provide must not revert; it delivers the buffer portion
vm.prank(executor);
vm.expectRevert(SafeLegManager.InsufficientValue.selector);
manager.provide(101e18, executor);
uint256 out = manager.provide(50e18, executor);
assertEq(out, 20e6); // 20 from buffer (down to min); PT best-effort
}

function test_m1_bufferOnlyPath_isOracleFree() public {
_inflow(100e6); // buffer 30, pt 70
pt.setFailValue(true); // pt.value() (oracle) would revert
// a request that fits the deliverable buffer must not touch the oracle
vm.prank(executor);
uint256 out = manager.provide(15e18, executor);
assertEq(out, 15e6);
}

function test_m2_onInflowPtBuyRevert_leavesAssetsInBuffer() public {
pt.setFailDeposit(true); // Pendle market dislocated
usdc.mint(address(manager), 100e6);
vm.prank(keeper);
manager.onInflow(); // must not revert
// assets stayed in the buffer instead of stranding at the adapter
assertEq(manager.bufferWad(), 100e18);
assertEq(pt.valueWad(), 0);
assertEq(manager.value(), 100e18);
}

function test_bufferRebalance_spillsAboveMax() public {
Expand Down