Skip to content

Commit 3801a2d

Browse files
committed
fuzz rounding error
1 parent 0e61d92 commit 3801a2d

File tree

4 files changed

+100
-19
lines changed

4 files changed

+100
-19
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
.devcontainer
12
.gitignore
23
.env
34
node_modules
@@ -26,4 +27,4 @@ gambit_out/
2627
test-mutation/mutant_test_env/
2728

2829
# bridged usdc deployment script
29-
registerUsdcGatewayTx.json
30+
registerUsdcGatewayTx.json

contracts/tokenbridge/test/FuzzSubVault.sol

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ contract FuzzSubVault is ERC20 {
2020
uint256 public maxDepositLimit = type(uint256).max;
2121
uint256 public maxRedeemLimit = type(uint256).max;
2222

23+
uint256 public depositErrorWad;
24+
uint256 public withdrawErrorWad;
25+
uint256 public redeemErrorWad;
26+
uint256 public previewMintErrorWad;
27+
uint256 public previewRedeemErrorWad;
28+
2329
constructor(IERC20 asset_, string memory _name, string memory _symbol) ERC20(_name, _symbol) {
2430
_asset = asset_;
2531
}
@@ -34,14 +40,14 @@ contract FuzzSubVault is ERC20 {
3440

3541
function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
3642
require(assets <= maxDepositLimit, "FuzzSubVault: deposit exceeds max");
37-
shares = _convertToShares(assets, Math.Rounding.Down);
43+
shares = _penalizeDown(_convertToShares(assets, Math.Rounding.Down), depositErrorWad);
3844
_asset.safeTransferFrom(msg.sender, address(this), assets);
3945
_mint(receiver, shares);
4046
}
4147

4248
function withdraw(uint256 assets, address receiver, address owner) external returns (uint256 shares) {
4349
require(assets <= maxWithdrawLimit, "FuzzSubVault: withdraw exceeds max");
44-
shares = _convertToShares(assets, Math.Rounding.Up);
50+
shares = _penalizeUp(_convertToShares(assets, Math.Rounding.Up), withdrawErrorWad);
4551
_burn(owner, shares);
4652
_asset.safeTransfer(receiver, assets);
4753
}
@@ -59,7 +65,7 @@ contract FuzzSubVault is ERC20 {
5965
}
6066

6167
function redeem(uint256 shares, address receiver, address owner) external returns (uint256 assets) {
62-
assets = _convertToAssets(shares, Math.Rounding.Down);
68+
assets = _penalizeDown(_convertToAssets(shares, Math.Rounding.Down), redeemErrorWad);
6369
require(shares <= maxRedeemLimit, "FuzzSubVault: redeem exceeds max");
6470
_burn(owner, shares);
6571
_asset.safeTransfer(receiver, assets);
@@ -70,11 +76,11 @@ contract FuzzSubVault is ERC20 {
7076
}
7177

7278
function previewMint(uint256 shares) public view returns (uint256) {
73-
return _convertToAssets(shares, Math.Rounding.Up);
79+
return _penalizeUp(_convertToAssets(shares, Math.Rounding.Up), previewMintErrorWad);
7480
}
7581

7682
function previewRedeem(uint256 shares) public view returns (uint256) {
77-
return _convertToAssets(shares, Math.Rounding.Down);
83+
return _penalizeDown(_convertToAssets(shares, Math.Rounding.Down), previewRedeemErrorWad);
7884
}
7985

8086
// --- Test helpers ---
@@ -99,8 +105,38 @@ contract FuzzSubVault is ERC20 {
99105
maxRedeemLimit = limit;
100106
}
101107

108+
function setDepositErrorWad(uint256 wad) external {
109+
depositErrorWad = wad;
110+
}
111+
112+
function setWithdrawErrorWad(uint256 wad) external {
113+
withdrawErrorWad = wad;
114+
}
115+
116+
function setRedeemErrorWad(uint256 wad) external {
117+
redeemErrorWad = wad;
118+
}
119+
120+
function setPreviewMintErrorWad(uint256 wad) external {
121+
previewMintErrorWad = wad;
122+
}
123+
124+
function setPreviewRedeemErrorWad(uint256 wad) external {
125+
previewRedeemErrorWad = wad;
126+
}
127+
102128
// --- Internal math ---
103129

130+
function _penalizeDown(uint256 value, uint256 errWad) private pure returns (uint256) {
131+
if (errWad == 0) return value;
132+
return value.mulDiv(1e18 - errWad, 1e18, Math.Rounding.Down);
133+
}
134+
135+
function _penalizeUp(uint256 value, uint256 errWad) private pure returns (uint256) {
136+
if (errWad == 0) return value;
137+
return value.mulDiv(1e18 + errWad, 1e18, Math.Rounding.Up);
138+
}
139+
104140
function _convertToShares(uint256 assets, Math.Rounding rounding) private view returns (uint256) {
105141
uint256 supply = totalSupply();
106142
if (supply == 0) return assets;

test-foundry/libraries/vault/invariant/MasterVaultHandler.sol

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ contract MasterVaultHandler is Test {
4040
/// @notice Count of successful calls per action (for debugging fuzzer coverage)
4141
mapping(bytes4 => uint256) public ghost_callCount;
4242

43-
/// @notice Whether subvault rate has ever been manipulated (loss/skew)
44-
bool public ghost_subVaultManipulated;
43+
/// @notice Whether subvault has been positively manipulated (profit-like: extra assets, deflated shares)
44+
bool public ghost_positiveManipulation;
45+
46+
/// @notice Whether subvault has been negatively manipulated (loss-like: removed assets, inflated shares, rounding errors)
47+
bool public ghost_negativeManipulation;
4548

4649
constructor(
4750
MasterVault _vault,
@@ -149,6 +152,7 @@ contract MasterVaultHandler is Test {
149152
token.mintAmount(amt);
150153
token.transfer(address(subVault), amt);
151154
ghost_profit += amt;
155+
ghost_positiveManipulation = true;
152156
ghost_callCount[this.simulateSubVaultProfit.selector]++;
153157
}
154158

@@ -161,15 +165,15 @@ contract MasterVaultHandler is Test {
161165
vm.prank(address(subVault));
162166
token.transfer(address(0xdead), amt);
163167
ghost_loss += amt;
164-
ghost_subVaultManipulated = true;
168+
ghost_negativeManipulation = true;
165169
ghost_callCount[this.simulateSubVaultLoss.selector]++;
166170
}
167171

168172
/// @notice Mint shares without backing assets on FuzzSubVault (T increases, A < T)
169173
function inflateSubVaultShares(uint256 amt) external {
170174
amt = bound(amt, 1, 1e24);
171175
subVault.adminMint(address(vault), amt);
172-
ghost_subVaultManipulated = true;
176+
ghost_negativeManipulation = true;
173177
ghost_callCount[this.inflateSubVaultShares.selector]++;
174178
}
175179

@@ -179,7 +183,7 @@ contract MasterVaultHandler is Test {
179183
if (vaultShares == 0) return;
180184
amt = bound(amt, 1, vaultShares);
181185
subVault.adminBurn(address(vault), amt);
182-
ghost_subVaultManipulated = true;
186+
ghost_positiveManipulation = true;
183187
ghost_callCount[this.deflateSubVaultShares.selector]++;
184188
}
185189

@@ -203,4 +207,44 @@ contract MasterVaultHandler is Test {
203207
subVault.setMaxRedeemLimit(lim);
204208
ghost_callCount[this.capSubVaultMaxRedeem.selector]++;
205209
}
210+
211+
/// @notice Set deposit rounding error on the subvault (0–10% in wad)
212+
function setDepositError(uint256 seed) external {
213+
uint256 wad = bound(seed, 0, 1e17);
214+
subVault.setDepositErrorWad(wad);
215+
if (wad > 0) ghost_negativeManipulation = true;
216+
ghost_callCount[this.setDepositError.selector]++;
217+
}
218+
219+
/// @notice Set withdraw rounding error on the subvault (0–10% in wad)
220+
function setWithdrawError(uint256 seed) external {
221+
uint256 wad = bound(seed, 0, 1e17);
222+
subVault.setWithdrawErrorWad(wad);
223+
if (wad > 0) ghost_negativeManipulation = true;
224+
ghost_callCount[this.setWithdrawError.selector]++;
225+
}
226+
227+
/// @notice Set redeem rounding error on the subvault (0–10% in wad)
228+
function setRedeemError(uint256 seed) external {
229+
uint256 wad = bound(seed, 0, 1e17);
230+
subVault.setRedeemErrorWad(wad);
231+
if (wad > 0) ghost_negativeManipulation = true;
232+
ghost_callCount[this.setRedeemError.selector]++;
233+
}
234+
235+
/// @notice Set previewMint rounding error on the subvault (0–10% in wad)
236+
function setPreviewMintError(uint256 seed) external {
237+
uint256 wad = bound(seed, 0, 1e17);
238+
subVault.setPreviewMintErrorWad(wad);
239+
if (wad > 0) ghost_negativeManipulation = true;
240+
ghost_callCount[this.setPreviewMintError.selector]++;
241+
}
242+
243+
/// @notice Set previewRedeem rounding error on the subvault (0–10% in wad)
244+
function setPreviewRedeemError(uint256 seed) external {
245+
uint256 wad = bound(seed, 0, 1e17);
246+
subVault.setPreviewRedeemErrorWad(wad);
247+
if (wad > 0) ghost_negativeManipulation = true;
248+
ghost_callCount[this.setPreviewRedeemError.selector]++;
249+
}
206250
}

test-foundry/libraries/vault/invariant/MasterVaultInvariant.t.sol

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ contract MasterVaultInvariant is Test {
8282
}
8383

8484
function invariant_canAlwaysSwitchSubVaults() public {
85-
_rebalanceToZero();
85+
if (_rebalanceToZero()) return;
8686

8787
FuzzSubVault newSubVault = new FuzzSubVault(IERC20(address(token)), "FuzzSub2", "fSUB2");
8888
vault.setSubVaultWhitelist(address(newSubVault), true);
@@ -140,11 +140,11 @@ contract MasterVaultInvariant is Test {
140140
/// @dev Under normal operation, the vault should never become insolvent.
141141
/// Fee distribution rounds down, so totalAssets may be up to 2 wei below totalPrincipal.
142142
/// Catches: value leakage through rounding, incorrect share pricing.
143-
function invariant_solvencyWhenNoManipulation() public {
144-
if (!handler.ghost_subVaultManipulated()) {
143+
function invariant_solvencyWhenNoNegativeManipulation() public {
144+
if (!handler.ghost_negativeManipulation()) {
145145
uint256 totalPrincipal = vault.totalSupply() / DEAD_SHARES;
146146
uint256 totalAssets = vault.totalAssets();
147-
assertGe(totalAssets + 2, totalPrincipal, "insolvent without manipulation");
147+
assertGe(totalAssets + 2, totalPrincipal, "insolvent without negative manipulation");
148148
}
149149
}
150150

@@ -158,21 +158,21 @@ contract MasterVaultInvariant is Test {
158158
);
159159
}
160160

161-
function _rebalanceToZero() internal {
161+
function _rebalanceToZero() internal returns (bool skip) {
162162
if (vault.targetAllocationWad() != 0) {
163163
vault.setTargetAllocationWad(0);
164164
}
165165

166166
uint256 shareBalance = vault.subVault().balanceOf(address(vault));
167-
if (shareBalance == 0) return;
167+
if (shareBalance == 0) return true;
168168

169169
uint256 maxRedeem = vault.subVault().maxRedeem(address(vault));
170-
if (maxRedeem == 0) return;
170+
if (maxRedeem == 0) return true;
171171

172172
uint256 iterationsRequired = (shareBalance) / maxRedeem + 1;
173173

174174
// set some reasonable upper bound on iterations to prevent infinite loop
175-
if (iterationsRequired > 10) return;
175+
if (iterationsRequired > 10) return true;
176176

177177
for (uint256 i = 0; i < iterationsRequired && vault.subVault().balanceOf(address(vault)) != 0; i++) {
178178
vm.warp(block.timestamp + 2);

0 commit comments

Comments
 (0)