<div align="center"> <center> <img src="https://avatars.githubusercontent.com/u/167952721" height="350" alt="@offbeatsecurity" style="margin-bottom: 20px;"> <h1>Wrapped Fixed LST Review</h1> <h3>September 26, 2025</h3> <p>Prepared for DappHero Corp</p> <p>Conducted by:</p> <p>Vara Prasad Bandaru (s3v3ru5)</p> </center> </div> ## About the **DappHero Corp Wrapped Fixed LST Review** Tally provides governance solutions that enable decentralized decision-making for blockchain protocols. The Staker contract allows tokens to be staked while still maintaining control over voting rights, ensuring that liquidity providers can participate in governance decisions. The WrappedGovLst contract is a wrapped, fixed (non-rebasing) variant of the liquid staking token, which provides a standard ERC20 interface for integrations that cannot handle rebasing tokens. ## About **Offbeat Security** Offbeat Security is a boutique security company providing unique security solutions for complex and novel crypto projects. Our mission is to elevate the blockchain security landscape through invention and collaboration. <div style="page-break-before: always;"></div> ## Summary & Scope The [src](https://github.com/withtally/stGOV/tree/39fa797b9670489ce8c2d4172fb0767eecd82252/src) folder of the `stGOV` repo was reviewed at commit [39fa797](https://github.com/withtally/stGOV/tree/39fa797b9670489ce8c2d4172fb0767eecd82252). The following **1 file** was in scope: - src/WrappedGovLst.sol The wrapped liquid staking token provides a fixed-supply ERC20 wrapper around the rebasing GovLst token. The contract handles conversions between the rebasing and non-rebasing representations while maintaining governance functionality. The review identified 1 LOW severity issue related to rounding calculations that favor users over the protocol when wrapping tokens. The finding involves precision loss in the share calculation mechanism during the wrap operation. ## Summary of Findings | Identifier | Title | Severity | Fixed | | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- | ----- | | [L-01](#L-01-Rounding-error-in-wrap-rebasing-function-benefits-user-instead-of-protocol) | Rounding error in wrap rebasing function <br> benefits user instead of protocol | Low | [PR#100](https://github.com/withtally/stGOV/pull/100) | ## Low Findings ### [L-01] Rounding error in wrap rebasing function benefits user instead of protocol #### Description The `WrappedGovLst.wrapRebasing()` function contains a rounding error that consistently favors users over the protocol when wrapping GovLst tokens. The issue stems from the interaction between the GovLst transfer mechanism and the WrappedGovLst minting process. When a user calls `wrapRebasing()`, the function transfers the requested amount of GovLst tokens from the user to the wrapper contract using `LST.transferFromAndReturnBalanceDiffs()`. ```solidity function wrapRebasing(uint256 _lstAmountToWrap) external virtual returns (uint256 _wrappedAmount) { [...] (, uint256 _receiverBalanceIncrease) = LST.transferFromAndReturnBalanceDiffs(msg.sender, address(this), _lstAmountToWrap); _wrappedAmount = FIXED_LST.convertToFixed(_receiverBalanceIncrease); ``` The GovLst transfer implementation uses `_calcSharesForStakeUp()` which rounds up the number of shares to transfer, ensuring the receiver gets at least the requested amount but potentially slightly more. ```solidity function _transfer(address _sender, address _receiver, uint256 _value) internal virtual returns (uint256, uint256) { // [...] // Move underlying shares. { uint256 _shares = _calcSharesForStakeUp(_value, _totals); _senderState.shares -= SafeCast.toUint128(_shares); _receiverState.shares += uint128(_shares); } } ``` The wrapper then mints wrapped tokens based on the actual balance increase (`_receiverBalanceIncrease`), which can be more than the originally requested amount due to the rounding up in share calculations. This allows users to receive wrapped tokens for more GovLst tokens than they transferred. A similar issue exists in `unwrapToRebasing()`. The function calls `FIXED_LST.convertToRebasing()`, which performs an internal transfer from the fixed alias address to the wrapper address. This first transfer returns `_receiverBalanceIncrease` (the wrapper's actual balance increase), which can exceed the requested conversion amount due to share rounding. The function then transfers this `_receiverBalanceIncrease` value to the user in a second transfer, allowing them to receive more LST tokens than their wrapped token balance should yield. **Exploit Scenario:** Consider the following state: - Scale factor = 10^10 - WLST address shares in GovLst = 111,111,111,111 = 11.11 * 10^10 - Total shares = 100 * 10^10 = 1,000,000,000,000 - Total amount = 297 - WLST balance = floor((aliceShares * totalAmount) / totalShares) = 32 When Bob wants to wrap 1 wei of GovLst: 1. **Share calculation**: `sharesToMove = _calcSharesForStakeUp(1) = ceil((1 * 1,000,000,000,000) / 297) = 3,367,003,368` 2. **Transfer execution**: - WLST new total shares = 111,111,111,111 + 3,367,003,368 = 114,478,114,479 - WLST new balance = floor((114,478,114,479 * 297) / 1,000,000,000,000) = 34 - `_receiverBalanceIncrease` = 34 - 32 = 2 3. **Result**: Bob receives wrapped tokens worth 2 wei of GovLst instead of the 1 wei he transferred. #### Recommendation Consider using the original user input amount instead of the actual balance increase when minting wrapped tokens: ```diff function wrapRebasing(uint256 _lstAmountToWrap) external virtual returns (uint256 _wrappedAmount) { if (_lstAmountToWrap == 0) { revert WrappedGovLst__InvalidAmount(); } (, uint256 _receiverBalanceIncrease) = LST.transferFromAndReturnBalanceDiffs(msg.sender, address(this), _lstAmountToWrap); - _wrappedAmount = FIXED_LST.convertToFixed(_receiverBalanceIncrease); + _wrappedAmount = FIXED_LST.convertToFixed(_lstAmountToWrap); _mint(msg.sender, _wrappedAmount); emit RebasingWrapped(msg.sender, _lstAmountToWrap, _wrappedAmount); } ``` For `unwrapToRebasing()`, modify the function to transfer the amount that was moved from the fixed alias to the wrapper, rather than using the return value from `convertToRebasing()`: ```diff function unwrapToRebasing(uint256 _wrappedAmount) external virtual returns (uint256) { if (_wrappedAmount == 0) { revert WrappedGovLst__InvalidAmount(); } _burn(msg.sender, _wrappedAmount); - uint256 _lstAmountUnwrapped = FIXED_LST.convertToRebasing(_wrappedAmount); + FIXED_LST.convertToRebasing(_wrappedAmount); + uint256 _lstAmountUnwrapped = _calcStakeForShares(_wrappedAmount * SHARE_SCALE_FACTOR); LST.transfer(msg.sender, _lstAmountUnwrapped); emit RebasingUnwrapped(msg.sender, _lstAmountUnwrapped, _wrappedAmount); return _lstAmountUnwrapped; } ``` Additionally, consider updating the preview functions to align with the updated implementation. Since the `LST._transfer()` function transfers shares equal to `_calcSharesForStakeUp(_amount)` and fixed tokens are scaled down versions of these shares, the wrapped amount will be exactly `_calcSharesForStakeUp(_amount) / SHARE_SCALE_FACTOR`. The current preview implementation subtracts 1 to provide a conservative estimate, but with the corrected wrapping logic, the preview can return the exact minimum amount: ```diff function previewWrapRebasing(uint256 _rebasingTokensToWrap) public view virtual returns (uint256) { - return _calcSharesForStakeUp(_rebasingTokensToWrap - 1) / SHARE_SCALE_FACTOR; + return _calcSharesForStakeUp(_rebasingTokensToWrap) / SHARE_SCALE_FACTOR; } ``` Similarly, for `previewUnwrapToRebasing()`, with the updated unwrapping logic that uses `_calcStakeForShares()` directly, the preview can return the exact amount without the conservative -1 adjustment: ```diff function previewUnwrapToRebasing(uint256 _wrappedAmount) public view virtual returns (uint256) { uint256 _shares = _wrappedAmount * SHARE_SCALE_FACTOR; - return _calcStakeForShares(_shares) - 1; + return _calcStakeForShares(_shares); } ```