Skip to content

Commit c3d947e

Browse files
authored
[N-01] Split Misleading ExchangeRateUpdated Event (#14)
2 parents f49deae + ba21af1 commit c3d947e

File tree

4 files changed

+63
-13
lines changed

4 files changed

+63
-13
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ Where:
416416
- Dead shares: 1000 shares minted to `0xdead` on first deposit (prevents share inflation / first depositor attack)
417417
- Cashback reserve: tracked separately from staking pool, protected from `emergencyWithdraw`
418418
- Staking reserve: tracked separately, protected from `emergencyWithdraw`, reclaimable via `defundStakingReserve()`
419-
- Emergency withdraw: for RNBW, only excess above `totalPooledRnbw + cashbackReserve + stakingReserve + undistributedFees` can be withdrawn -- the pool, both reserves, and pending drip fees are untouchable. Non-RNBW tokens have no restriction (rescue for accidental sends).
419+
- Emergency withdraw: for RNBW, the transfer is capped at excess above `totalPooledRnbw + cashbackReserve + stakingReserve + undistributedFees` -- if the requested amount exceeds excess, it is silently capped (check `EmergencyWithdrawn` event for actual amount). The pool, both reserves, and pending drip fees are untouchable. Non-RNBW tokens pass through uncapped (rescue for accidental sends).
420420
- Min stake floor: `minStakeAmount` cannot be set below 1 RNBW
421421
- Inflation guard: `ZeroSharesMinted` revert protects depositors from rounding attacks
422422
- Residual sweep: when only dead shares remain, `totalPooledRnbw` and `undistributedFees` are swept to safe and pool is reset
@@ -542,7 +542,7 @@ Backend responsibility: filter amounts that would mint 0 shares at current rate,
542542

543543
### No admin access to pool or reserve
544544

545-
There is no function that lets the admin withdraw from `totalPooledRnbw`. Pool RNBW is only withdrawable by stakers burning shares. Cashback reserve is consumable via signed allocations or reclaimable via `defundCashbackReserve()`. Staking reserve is consumable via `stakeForWithSignature` or reclaimable via `defundStakingReserve()`. `emergencyWithdraw` is restricted to excess RNBW above all four tracked pools (`totalPooledRnbw + cashbackReserve + stakingReserve + undistributedFees`). To wind down the protocol: stop new stakes (disable frontend / revoke signers), let existing users unstake normally, residual sweeps to safe when pool empties. Note: `pause()` is an emergency brake that freezes everything including unstaking -- it is not a wind-down mechanism.
545+
There is no function that lets the admin withdraw from `totalPooledRnbw`. Pool RNBW is only withdrawable by stakers burning shares. Cashback reserve is consumable via signed allocations or reclaimable via `defundCashbackReserve()`. Staking reserve is consumable via `stakeForWithSignature` or reclaimable via `defundStakingReserve()`. `emergencyWithdraw` caps RNBW transfers at excess above all four tracked pools (`totalPooledRnbw + cashbackReserve + stakingReserve + undistributedFees`) -- requested amounts above excess are silently capped. To wind down the protocol: stop new stakes (disable frontend / revoke signers), let existing users unstake normally, residual sweeps to safe when pool empties. Note: `pause()` is an emergency brake that freezes everything including unstaking -- it is not a wind-down mechanism.
546546

547547
## Future: Liquid Staking Token (xRNBW)
548548

src/RNBWStaking.sol

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -288,16 +288,11 @@ contract RNBWStaking is IRNBWStaking, ReentrancyGuard, Pausable, EIP712 {
288288
/// @inheritdoc IRNBWStaking
289289
function previewStake(address user, uint256 amount) external view returns (uint256 sharesToMint) {
290290
if (shares[user] == 0 && amount < minStakeAmount) return 0;
291-
if (totalShares == 0) {
292-
if (amount <= MINIMUM_SHARES) return 0;
293-
sharesToMint = amount - MINIMUM_SHARES;
294-
} else {
295-
sharesToMint = (amount * totalShares) / _effectivePooledRnbw();
296-
}
291+
return getSharesForRnbw(amount);
297292
}
298293

299294
/// @inheritdoc IRNBWStaking
300-
function isNonceUsed(address user, uint256 nonce) external view returns (bool) {
295+
function isNonceUsed(address user, uint256 nonce) public view returns (bool) {
301296
return usedNonces[user][nonce];
302297
}
303298

@@ -354,7 +349,8 @@ contract RNBWStaking is IRNBWStaking, ReentrancyGuard, Pausable, EIP712 {
354349
uint256 balance = RNBW_TOKEN.balanceOf(address(this));
355350
uint256 reserved = totalPooledRnbw + cashbackReserve + stakingReserve + undistributedFees;
356351
uint256 excess = balance > reserved ? balance - reserved : 0;
357-
if (amount > excess) revert InsufficientExcess();
352+
if (excess == 0) revert InsufficientExcess();
353+
amount = amount > excess ? excess : amount;
358354
}
359355
IERC20(token).safeTransfer(safe, amount);
360356
emit EmergencyWithdrawn(token, amount);
@@ -364,6 +360,9 @@ contract RNBWStaking is IRNBWStaking, ReentrancyGuard, Pausable, EIP712 {
364360
function proposeSafe(address newSafe) external onlySafe {
365361
if (newSafe == address(0)) revert ZeroAddress();
366362
if (newSafe == safe) revert NoChange();
363+
if (pendingSafe != address(0)) {
364+
emit SafeProposalCancelled(safe, pendingSafe);
365+
}
367366
pendingSafe = newSafe;
368367
emit SafeProposed(safe, newSafe);
369368
}
@@ -593,7 +592,7 @@ contract RNBWStaking is IRNBWStaking, ReentrancyGuard, Pausable, EIP712 {
593592
if (block.timestamp > expiry) revert SignatureExpired();
594593

595594
// 2. Check nonce hasn't been used (prevents replay attacks)
596-
if (usedNonces[user][nonce]) revert NonceAlreadyUsed();
595+
if (isNonceUsed(user, nonce)) revert NonceAlreadyUsed();
597596

598597
// 3. Recover signer from EIP-712 typed data hash
599598
bytes32 digest = _hashTypedDataV4(structHash);
@@ -704,6 +703,7 @@ contract RNBWStaking is IRNBWStaking, ReentrancyGuard, Pausable, EIP712 {
704703

705704
// 10. Emit events
706705
emit Unstaked(user, sharesToBurn, rnbwValue, exitFee, netAmount);
706+
emit PoolTotalsUpdated(totalPooledRnbw, totalShares);
707707
}
708708

709709
/// @dev Stakes from the pre-funded staking reserve — no token transfer,
@@ -757,6 +757,7 @@ contract RNBWStaking is IRNBWStaking, ReentrancyGuard, Pausable, EIP712 {
757757

758758
// 5. Emit events
759759
emit Staked(user, amount, sharesToMint, shares[user]);
760+
emit PoolTotalsUpdated(totalPooledRnbw, totalShares);
760761
}
761762

762763
/// @dev Allocates cashback by minting shares directly in one step.
@@ -773,7 +774,7 @@ contract RNBWStaking is IRNBWStaking, ReentrancyGuard, Pausable, EIP712 {
773774
}
774775

775776
// 3. Calculate shares to mint at the current exchange rate
776-
assert(totalShares > 0);
777+
// totalShares > 0 guaranteed by shares[user] > 0 check above.
777778
uint256 sharesToMint = (rnbwCashback * totalShares) / totalPooledRnbw;
778779

779780
// 4. Revert if cashback is too small to mint shares — backend should
@@ -794,5 +795,6 @@ contract RNBWStaking is IRNBWStaking, ReentrancyGuard, Pausable, EIP712 {
794795

795796
// 7. Emit events
796797
emit CashbackAllocated(user, rnbwCashback, sharesToMint);
798+
emit PoolTotalsUpdated(totalPooledRnbw, totalShares);
797799
}
798800
}

src/interfaces/IRNBWStaking.sol

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ interface IRNBWStaking {
5959
/// @param totalShares The total shares outstanding
6060
event ExchangeRateUpdated(uint256 totalPooledRnbw, uint256 totalShares);
6161

62+
/// @notice Emitted after stake, unstake, or cashback allocation — pool totals change but
63+
/// the exchange rate stays ~constant (both sides scale proportionally, modulo rounding dust).
64+
/// @param totalPooledRnbw The total RNBW in the staking pool
65+
/// @param totalShares The total shares outstanding
66+
event PoolTotalsUpdated(uint256 totalPooledRnbw, uint256 totalShares);
67+
6268
/// @notice Emitted when the exit fee is updated
6369
/// @param oldExitFeeBps The previous exit fee in basis points
6470
/// @param newExitFeeBps The new exit fee in basis points
@@ -416,7 +422,8 @@ interface IRNBWStaking {
416422
/// @param amount The amount of RNBW to reclaim
417423
function defundCashbackReserve(uint256 amount) external;
418424

419-
/// @notice Propose a new safe address (step 1 of 2-step transfer, callable by current safe only)
425+
/// @notice Propose a new safe address (step 1 of 2-step transfer, callable by current safe only).
426+
/// If a previous proposal exists, it is implicitly cancelled (emits SafeProposalCancelled).
420427
/// @param newSafe The proposed new safe address
421428
function proposeSafe(address newSafe) external;
422429

test/RNBWStaking.t.sol

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ contract RNBWStakingTest is Test {
103103
assertEq(rnbwToken.balanceOf(alice), INITIAL_BALANCE - amount);
104104
}
105105

106+
function test_StakeEmitsPoolTotalsUpdated() public {
107+
vm.startPrank(alice);
108+
rnbwToken.approve(address(staking), 100 ether);
109+
vm.expectEmit(true, true, false, true);
110+
emit IRNBWStaking.PoolTotalsUpdated(100 ether, 100 ether);
111+
staking.stake(100 ether);
112+
vm.stopPrank();
113+
}
114+
106115
function test_StakeRevertZeroAmount() public {
107116
vm.prank(alice);
108117
vm.expectRevert(IRNBWStaking.ZeroAmount.selector);
@@ -309,6 +318,23 @@ contract RNBWStakingTest is Test {
309318
assertEq(staking.totalPooledRnbw(), 110 ether);
310319
}
311320

321+
function test_AllocateCashbackEmitsPoolTotalsUpdated() public {
322+
vm.startPrank(alice);
323+
rnbwToken.approve(address(staking), 100 ether);
324+
staking.stake(100 ether);
325+
vm.stopPrank();
326+
327+
_depositCashback(10 ether);
328+
329+
uint256 nonce = 1;
330+
uint256 expiry = block.timestamp + 60;
331+
bytes memory sig = _signAllocateCashback(alice, 10 ether, nonce, expiry);
332+
333+
vm.expectEmit(true, true, false, true);
334+
emit IRNBWStaking.PoolTotalsUpdated(110 ether, 110 ether);
335+
staking.allocateCashbackWithSignature(alice, 10 ether, nonce, expiry, sig);
336+
}
337+
312338
function test_AllocateCashbackRevertNoPosition() public {
313339
_depositCashback(10 ether);
314340

@@ -520,6 +546,21 @@ contract RNBWStakingTest is Test {
520546
assertEq(rnbwToken.balanceOf(admin), 50 ether);
521547
}
522548

549+
function test_EmergencyWithdrawCapsAtExcess() public {
550+
vm.startPrank(alice);
551+
rnbwToken.approve(address(staking), 100 ether);
552+
staking.stake(100 ether);
553+
vm.stopPrank();
554+
555+
rnbwToken.mint(address(staking), 30 ether);
556+
557+
uint256 adminBefore = rnbwToken.balanceOf(admin);
558+
vm.prank(admin);
559+
staking.emergencyWithdraw(address(rnbwToken), 100 ether);
560+
561+
assertEq(rnbwToken.balanceOf(admin) - adminBefore, 30 ether);
562+
}
563+
523564
function test_EmergencyWithdrawRevertInsufficientExcess() public {
524565
vm.startPrank(alice);
525566
rnbwToken.approve(address(staking), 100 ether);

0 commit comments

Comments
 (0)