YoVault redemption upgrade

Vault contract upgrades are deployed to improve security, efficiency, and functionality. Each upgrade undergoes thorough auditing and governance approval before deployment.

Scheduled: September 17, 2026

Upgrade In
--

Hours

:
--

Min

:
--

Sec

Local: September 17, 2026 at 3:00 PM

UTC: September 17, 2026 at 3:00 PM

Summary

Upgrades the YoVault implementation on Ethereum, Base, Arbitrum and HyperEVM to a single new implementation with a reworked redemption settlement. Operators now fulfil pending redemptions in shares: the reserved gross value is released pro rata, so a request can be settled in several partial fulfilments with no dust left behind. Fulfilment defaults to the price reserved at request time. The operator may settle at the current oracle price only when it is at or below the request price, so it can pass on a haircut on the underlying but can never draw on liquidity reserved for other users. Cancellation now unwinds a receiver's whole pending request, and neither fulfilment nor cancellation runs while the vault is paused. Redemption requests naming the vault itself as receiver are rejected. Storage layout is preserved and share balances, pricing, deposits and instant redemptions are unchanged, so no user action is required. Audited by Hashlock (September 2026) with no medium or high severity findings. The change is timelocked for 48 hours and executed by the admin multisig.

Code Changes

contracts/YoVault.sol
+47-20
@@ -194,6 +194,7 @@
194194 /// @return Asset amount on instant redemption, or `REQUEST_ID` (0) when queued.
195195 function requestRedeem(uint256 shares, address receiver, address owner) public whenNotPaused returns (uint256) {
196196 require(receiver != address(0), Errors.ZeroReceiver());
197+ require(receiver != address(this), Errors.SelfReceiverNotAllowed());
197198 require(shares > 0, Errors.SharesAmountZero());
198199 require(owner == msg.sender, Errors.NotSharesOwner());
199200 require(balanceOf(owner) >= shares, Errors.InsufficientShares());
@@ -219,39 +220,65 @@
219220 return REQUEST_ID;
220221 }
221222
222- /// @notice Fulfill a pending redemption — burns escrowed shares and transfers assets.
223+ /// @notice Fulfill `shares` of a receiver's pending redemption — burns the escrowed shares and
224+ /// pays the assets. Reverts while paused.
225+ /// @dev The reserved gross is released in proportion to `shares`; the final fulfilment takes
226+ /// the exact remainder so no dust is left behind. The operator chooses the price: the
227+ /// reserved gross (the default), or the current oracle price (`atCurrentPrice`) for
228+ /// exceptional events such as a haircut on the underlying. Current-price settlement is
229+ /// refused when the entry's current value exceeds its reservation, so it can only reduce
230+ /// the payout and never draws on liquidity reserved for others. The withdrawal fee
231+ /// applies at the live rate either way; `totalPendingAssets` always releases the reserved
232+ /// amount. A slice whose gross value rounds to zero is refused. Only the current-price
233+ /// path reads the oracle.
223234 /// @param receiver Address whose pending request is being fulfilled.
224- /// @param shares Amount of escrowed shares to burn.
225- /// @param assetsWithFee Gross asset amount (including withdrawal fee).
226- function fulfillRedeem(address receiver, uint256 shares, uint256 assetsWithFee) external requiresAuth {
235+ /// @param shares Escrowed shares to settle; the reserved assets follow proportionally.
236+ /// @param atCurrentPrice Pay the shares at the current oracle price instead of the reserved gross.
237+ function fulfillRedeem(address receiver, uint256 shares, bool atCurrentPrice) external requiresAuth whenNotPaused {
227238 PendingRedeem storage pending = _pendingRedeem[receiver];
228- require(pending.shares != 0 && shares <= pending.shares, Errors.InvalidSharesAmount());
229- require(pending.assets != 0 && assetsWithFee <= pending.assets, Errors.InvalidAssetsAmount());
239+ uint256 pendingShares = pending.shares;
240+ require(shares != 0 && shares <= pendingShares, Errors.InvalidSharesAmount());
241+ uint256 pendingAssets = pending.assets;
230242
231- pending.shares -= shares;
232- pending.assets -= assetsWithFee;
233- totalPendingAssets -= assetsWithFee;
243+ // Pro-rata release; on the final slice `mulDiv(a, n, n) == a`, so the exact remainder settles.
244+ uint256 reservedAssets = pendingAssets.mulDiv(shares, pendingShares, Math.Rounding.Floor);
245+ uint256 assetsWithFee = reservedAssets;
246+ if (atCurrentPrice) {
247+ // Judged on the whole entry. Reservations were floored per request, so an entry built
248+ // from several requests can exceed its reservation by up to (requests - 1) wei at an
249+ // unchanged price and be refused; use the request price then — the payout is the same.
250+ uint256 currentValue = _convertToAssets(pendingShares, Math.Rounding.Floor);
251+ require(currentValue <= pendingAssets, Errors.CurrentPriceAboveRequestPrice(currentValue, pendingAssets));
252+ assetsWithFee = currentValue.mulDiv(shares, pendingShares, Math.Rounding.Floor);
253+ }
254+ // Refuse slices whose gross rounds to zero, so at most sub-wei rounding is ever parked in
255+ // the reservation. (The receiver's net can still round to zero on wei-sized slices under a
256+ // nonzero fee; that wei is collected as fee, not lost.)
257+ require(assetsWithFee != 0, Errors.InvalidAssetsAmount());
234258
259+ pending.shares = pendingShares - shares;
260+ pending.assets = pendingAssets - reservedAssets;
261+ totalPendingAssets -= reservedAssets;
262+
235263 emit RequestFulfilled(receiver, shares, assetsWithFee);
236264 // burn the shares from the vault and transfer the assets to the receiver
237265 _withdraw(address(this), receiver, address(this), assetsWithFee, shares);
238266 }
239267
240- /// @notice Cancel a pending redemption — returns escrowed shares to the receiver.
268+ /// @notice Cancel a receiver's whole pending redemption — returns the escrowed shares to the
269+ /// receiver. Reverts while paused.
241270 /// @param receiver Address whose pending request is being cancelled.
242- /// @param shares Amount of escrowed shares to return.
243- /// @param assetsWithFee Gross asset amount to release from the pending total.
244- function cancelRedeem(address receiver, uint256 shares, uint256 assetsWithFee) external requiresAuth {
271+ function cancelRedeem(address receiver) external requiresAuth whenNotPaused {
245272 PendingRedeem storage pending = _pendingRedeem[receiver];
246- require(pending.shares != 0 && shares <= pending.shares, Errors.InvalidSharesAmount());
247- require(pending.assets != 0 && assetsWithFee <= pending.assets, Errors.InvalidAssetsAmount());
273+ uint256 shares = pending.shares;
274+ require(shares != 0, Errors.InvalidSharesAmount());
275+ uint256 reservedAssets = pending.assets;
248276
249- pending.shares -= shares;
250- pending.assets -= assetsWithFee;
251- totalPendingAssets -= assetsWithFee;
277+ delete _pendingRedeem[receiver];
278+ totalPendingAssets -= reservedAssets;
252279
253- emit RequestCancelled(receiver, shares, assetsWithFee);
254- // transfer the shares back to the owner
280+ emit RequestCancelled(receiver, shares, reservedAssets);
281+ // return the escrowed shares to the receiver
255282 _transfer(address(this), receiver, shares);
256283 }
257284

Previous Upgrades