@bananapus/suckers-v6 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILLS.md +1 -1
- package/package.json +4 -4
- package/src/JBSucker.sol +10 -0
- package/test/regression/L47_MapTokensDust.t.sol +242 -0
package/SKILLS.md
CHANGED
|
@@ -31,7 +31,7 @@ Cross-chain token and fund bridging for Juicebox V6 projects, using merkle trees
|
|
|
31
31
|
| `claim(claimData)` | `JBSucker` | Verifies a merkle proof against the inbox tree, marks the leaf as executed (prevents double-spend), mints project tokens for the beneficiary via `IJBController.mintTokensOf` (with `useReservedPercent: false`), and optionally adds terminal tokens to the project's balance if `ADD_TO_BALANCE_MODE == ON_CLAIM`. |
|
|
32
32
|
| `claim(claims[])` | `JBSucker` | Batch version -- iterates and calls `claim(JBClaim)` for each. |
|
|
33
33
|
| `mapToken(map)` | `JBSucker` | Maps a local terminal token to a remote token. Requires `MAP_SUCKER_TOKEN` permission. Setting `remoteToken` to `bytes32(0)` disables bridging and sends a final root to flush remaining outbox. Cannot remap to a different remote token once outbox has entries (prevents double-spend). Can re-enable a previously disabled token to the same remote address. Reverts if emergency hatch is active for the token. |
|
|
34
|
-
| `mapTokens(maps[])` | `JBSucker` | Batch version of `mapToken`. Splits `msg.value` evenly across mappings that need a final root flush (disable with pending outbox entries). |
|
|
34
|
+
| `mapTokens(maps[])` | `JBSucker` | Batch version of `mapToken`. Splits `msg.value` evenly across mappings that need a final root flush (disable with pending outbox entries). Refunds any remainder (dust) from integer division back to the caller on a best-effort basis (L-47). |
|
|
35
35
|
| `addOutstandingAmountToBalance(token)` | `JBSucker` | Manually adds received terminal tokens to the project's balance via the primary terminal's `addToBalanceOf`. Only callable when `ADD_TO_BALANCE_MODE == MANUAL`. |
|
|
36
36
|
| `enableEmergencyHatchFor(tokens)` | `JBSucker` | Opens emergency hatch for specified tokens (irreversible). Sets `emergencyHatch = true` and `enabled = false` on each token's `JBRemoteToken`. Requires `SUCKER_SAFETY` permission from the project owner. |
|
|
37
37
|
| `exitThroughEmergencyHatch(claimData)` | `JBSucker` | Lets users reclaim tokens on the chain they deposited, using their **outbox** proof (not inbox). Only works when emergency hatch is open for the token OR sucker is `SENDING_DISABLED`/`DEPRECATED`. Only allows exit for leaves with index >= `numberOfClaimsSent` (leaves not yet sent to remote). Decreases `outbox.balance`. Uses a separate execution bitmap slot (derived from `keccak256(abi.encode(terminalToken))`) to avoid collision with inbox claim tracking. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/suckers-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@arbitrum/nitro-contracts": "github:OffchainLabs/nitro-contracts",
|
|
22
|
-
"@bananapus/core-v6": "^0.0.
|
|
23
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
22
|
+
"@bananapus/core-v6": "^0.0.9",
|
|
23
|
+
"@bananapus/permission-ids-v6": "^0.0.4",
|
|
24
24
|
"@chainlink/contracts-ccip": "^1.5.0",
|
|
25
25
|
"@chainlink/local": "github:smartcontractkit/chainlink-local",
|
|
26
26
|
"@openzeppelin/contracts": "^5.2.0",
|
|
@@ -28,6 +28,6 @@
|
|
|
28
28
|
"solady": "^0.1.8"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@sphinx-labs/plugins": "^0.33.
|
|
31
|
+
"@sphinx-labs/plugins": "^0.33.2"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/src/JBSucker.sol
CHANGED
|
@@ -491,6 +491,16 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
491
491
|
// slither-disable-next-line msg-value-loop
|
|
492
492
|
_mapToken({map: maps[i], transportPaymentValue: numberToDisable > 0 ? msg.value / numberToDisable : 0});
|
|
493
493
|
}
|
|
494
|
+
|
|
495
|
+
// Refund any remainder from integer division so dust wei isn't stuck in the contract.
|
|
496
|
+
if (numberToDisable > 0) {
|
|
497
|
+
uint256 remainder = msg.value % numberToDisable;
|
|
498
|
+
if (remainder > 0) {
|
|
499
|
+
// Best-effort refund — don't revert if caller can't accept ETH.
|
|
500
|
+
// slither-disable-next-line low-level-calls,unchecked-lowlevel
|
|
501
|
+
_msgSender().call{value: remainder}("");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
494
504
|
}
|
|
495
505
|
|
|
496
506
|
/// @notice Enables the emergency hatch for a list of tokens, allowing users to exit on the chain they deposited on.
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.13;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import "../../src/JBSucker.sol";
|
|
7
|
+
import {LibClone} from "solady/src/utils/LibClone.sol";
|
|
8
|
+
import {MerkleLib} from "../../src/utils/MerkleLib.sol";
|
|
9
|
+
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
|
|
10
|
+
|
|
11
|
+
/// @notice Regression test for L-47: mapTokens msg.value dust from integer division.
|
|
12
|
+
/// When `msg.value / numberToDisable` has a remainder, the dust wei must be refunded to the caller.
|
|
13
|
+
contract L47_MapTokensDustTest is Test {
|
|
14
|
+
address constant DIRECTORY = address(600);
|
|
15
|
+
address constant PERMISSIONS = address(800);
|
|
16
|
+
address constant TOKENS = address(700);
|
|
17
|
+
address constant CONTROLLER = address(900);
|
|
18
|
+
address constant PROJECT = address(1000);
|
|
19
|
+
address constant FORWARDER = address(1100);
|
|
20
|
+
|
|
21
|
+
address tokenA = makeAddr("tokenA");
|
|
22
|
+
address tokenB = makeAddr("tokenB");
|
|
23
|
+
address tokenC = makeAddr("tokenC");
|
|
24
|
+
|
|
25
|
+
bytes32 remoteA = bytes32(uint256(1));
|
|
26
|
+
bytes32 remoteB = bytes32(uint256(2));
|
|
27
|
+
bytes32 remoteC = bytes32(uint256(3));
|
|
28
|
+
|
|
29
|
+
uint256 projectId = 1;
|
|
30
|
+
|
|
31
|
+
function setUp() public {
|
|
32
|
+
vm.label(DIRECTORY, "MOCK_DIRECTORY");
|
|
33
|
+
vm.label(PERMISSIONS, "MOCK_PERMISSIONS");
|
|
34
|
+
vm.label(TOKENS, "MOCK_TOKENS");
|
|
35
|
+
vm.label(CONTROLLER, "MOCK_CONTROLLER");
|
|
36
|
+
vm.label(PROJECT, "MOCK_PROJECT");
|
|
37
|
+
|
|
38
|
+
// Mock DIRECTORY.PROJECTS() and ownerOf for all tests.
|
|
39
|
+
vm.mockCall(DIRECTORY, abi.encodeCall(IJBDirectory.PROJECTS, ()), abi.encode(PROJECT));
|
|
40
|
+
vm.mockCall(PROJECT, abi.encodeCall(IERC721.ownerOf, (projectId)), abi.encode(address(this)));
|
|
41
|
+
|
|
42
|
+
// Allow any caller to pass the MAP_SUCKER_TOKEN permission check.
|
|
43
|
+
vm.mockCall(PERMISSIONS, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// @notice When mapTokens disables multiple tokens and msg.value is not evenly divisible,
|
|
47
|
+
/// the remainder (dust) must be refunded to the caller.
|
|
48
|
+
function test_mapTokensDustRefunded() external {
|
|
49
|
+
L47TestSucker sucker = _createTestSucker(projectId, "");
|
|
50
|
+
|
|
51
|
+
// Set up two tokens that are currently mapped and have unsent outbox entries,
|
|
52
|
+
// so disabling them (remoteToken=0) triggers _sendRoot which needs transport payment.
|
|
53
|
+
|
|
54
|
+
// Token A: mapped with unsent claims (tree.count=1, numberOfClaimsSent=0).
|
|
55
|
+
sucker.test_setRemoteToken(
|
|
56
|
+
tokenA,
|
|
57
|
+
JBRemoteToken({enabled: true, emergencyHatch: false, minGas: 200_000, addr: remoteA, minBridgeAmount: 0})
|
|
58
|
+
);
|
|
59
|
+
sucker.test_setOutboxTreeCount(tokenA, 1);
|
|
60
|
+
// numberOfClaimsSent defaults to 0, so 0 != 1 means this token will be counted.
|
|
61
|
+
|
|
62
|
+
// Token B: mapped with unsent claims.
|
|
63
|
+
sucker.test_setRemoteToken(
|
|
64
|
+
tokenB,
|
|
65
|
+
JBRemoteToken({enabled: true, emergencyHatch: false, minGas: 200_000, addr: remoteB, minBridgeAmount: 0})
|
|
66
|
+
);
|
|
67
|
+
sucker.test_setOutboxTreeCount(tokenB, 1);
|
|
68
|
+
|
|
69
|
+
// Token C: mapped with unsent claims.
|
|
70
|
+
sucker.test_setRemoteToken(
|
|
71
|
+
tokenC,
|
|
72
|
+
JBRemoteToken({enabled: true, emergencyHatch: false, minGas: 200_000, addr: remoteC, minBridgeAmount: 0})
|
|
73
|
+
);
|
|
74
|
+
sucker.test_setOutboxTreeCount(tokenC, 1);
|
|
75
|
+
|
|
76
|
+
// Build maps to disable all three tokens (remoteToken = 0).
|
|
77
|
+
JBTokenMapping[] memory maps = new JBTokenMapping[](3);
|
|
78
|
+
maps[0] = JBTokenMapping({localToken: tokenA, minGas: 200_000, remoteToken: bytes32(0), minBridgeAmount: 0});
|
|
79
|
+
maps[1] = JBTokenMapping({localToken: tokenB, minGas: 200_000, remoteToken: bytes32(0), minBridgeAmount: 0});
|
|
80
|
+
maps[2] = JBTokenMapping({localToken: tokenC, minGas: 200_000, remoteToken: bytes32(0), minBridgeAmount: 0});
|
|
81
|
+
|
|
82
|
+
// Send 10 wei with 3 tokens to disable: 10 / 3 = 3 each, remainder = 1 wei.
|
|
83
|
+
uint256 msgValue = 10;
|
|
84
|
+
uint256 expectedRemainder = msgValue % 3; // 1 wei
|
|
85
|
+
|
|
86
|
+
address caller = makeAddr("caller");
|
|
87
|
+
vm.deal(caller, msgValue);
|
|
88
|
+
|
|
89
|
+
uint256 callerBalanceBefore = caller.balance;
|
|
90
|
+
|
|
91
|
+
vm.prank(caller);
|
|
92
|
+
sucker.mapTokens{value: msgValue}(maps);
|
|
93
|
+
|
|
94
|
+
uint256 callerBalanceAfter = caller.balance;
|
|
95
|
+
|
|
96
|
+
// The caller should have received the 1 wei remainder back.
|
|
97
|
+
assertEq(callerBalanceAfter, callerBalanceBefore - msgValue + expectedRemainder, "Dust remainder not refunded");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// @notice When msg.value is evenly divisible, no refund is needed and nothing reverts.
|
|
101
|
+
function test_mapTokensNoDustWhenEvenlyDivisible() external {
|
|
102
|
+
L47TestSucker sucker = _createTestSucker(projectId, "");
|
|
103
|
+
|
|
104
|
+
sucker.test_setRemoteToken(
|
|
105
|
+
tokenA,
|
|
106
|
+
JBRemoteToken({enabled: true, emergencyHatch: false, minGas: 200_000, addr: remoteA, minBridgeAmount: 0})
|
|
107
|
+
);
|
|
108
|
+
sucker.test_setOutboxTreeCount(tokenA, 1);
|
|
109
|
+
|
|
110
|
+
sucker.test_setRemoteToken(
|
|
111
|
+
tokenB,
|
|
112
|
+
JBRemoteToken({enabled: true, emergencyHatch: false, minGas: 200_000, addr: remoteB, minBridgeAmount: 0})
|
|
113
|
+
);
|
|
114
|
+
sucker.test_setOutboxTreeCount(tokenB, 1);
|
|
115
|
+
|
|
116
|
+
JBTokenMapping[] memory maps = new JBTokenMapping[](2);
|
|
117
|
+
maps[0] = JBTokenMapping({localToken: tokenA, minGas: 200_000, remoteToken: bytes32(0), minBridgeAmount: 0});
|
|
118
|
+
maps[1] = JBTokenMapping({localToken: tokenB, minGas: 200_000, remoteToken: bytes32(0), minBridgeAmount: 0});
|
|
119
|
+
|
|
120
|
+
// 10 / 2 = 5 each, no remainder.
|
|
121
|
+
uint256 msgValue = 10;
|
|
122
|
+
|
|
123
|
+
address caller = makeAddr("caller");
|
|
124
|
+
vm.deal(caller, msgValue);
|
|
125
|
+
|
|
126
|
+
uint256 callerBalanceBefore = caller.balance;
|
|
127
|
+
|
|
128
|
+
vm.prank(caller);
|
|
129
|
+
sucker.mapTokens{value: msgValue}(maps);
|
|
130
|
+
|
|
131
|
+
uint256 callerBalanceAfter = caller.balance;
|
|
132
|
+
|
|
133
|
+
// No remainder, so caller balance should just decrease by msgValue.
|
|
134
|
+
assertEq(callerBalanceAfter, callerBalanceBefore - msgValue, "Balance should decrease by exact msgValue");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// @notice Fuzz test: for any msg.value and any numberToDisable (1-10), dust is always refunded.
|
|
138
|
+
function test_mapTokensDustFuzz(uint256 msgValue) external {
|
|
139
|
+
// Bound to avoid overflow and keep test tractable. Use 3 tokens to disable.
|
|
140
|
+
msgValue = bound(msgValue, 0, 10 ether);
|
|
141
|
+
uint256 numberToDisable = 3;
|
|
142
|
+
uint256 expectedRemainder = msgValue % numberToDisable;
|
|
143
|
+
|
|
144
|
+
L47TestSucker sucker = _createTestSucker(projectId, "fuzz");
|
|
145
|
+
|
|
146
|
+
sucker.test_setRemoteToken(
|
|
147
|
+
tokenA,
|
|
148
|
+
JBRemoteToken({enabled: true, emergencyHatch: false, minGas: 200_000, addr: remoteA, minBridgeAmount: 0})
|
|
149
|
+
);
|
|
150
|
+
sucker.test_setOutboxTreeCount(tokenA, 1);
|
|
151
|
+
|
|
152
|
+
sucker.test_setRemoteToken(
|
|
153
|
+
tokenB,
|
|
154
|
+
JBRemoteToken({enabled: true, emergencyHatch: false, minGas: 200_000, addr: remoteB, minBridgeAmount: 0})
|
|
155
|
+
);
|
|
156
|
+
sucker.test_setOutboxTreeCount(tokenB, 1);
|
|
157
|
+
|
|
158
|
+
sucker.test_setRemoteToken(
|
|
159
|
+
tokenC,
|
|
160
|
+
JBRemoteToken({enabled: true, emergencyHatch: false, minGas: 200_000, addr: remoteC, minBridgeAmount: 0})
|
|
161
|
+
);
|
|
162
|
+
sucker.test_setOutboxTreeCount(tokenC, 1);
|
|
163
|
+
|
|
164
|
+
JBTokenMapping[] memory maps = new JBTokenMapping[](3);
|
|
165
|
+
maps[0] = JBTokenMapping({localToken: tokenA, minGas: 200_000, remoteToken: bytes32(0), minBridgeAmount: 0});
|
|
166
|
+
maps[1] = JBTokenMapping({localToken: tokenB, minGas: 200_000, remoteToken: bytes32(0), minBridgeAmount: 0});
|
|
167
|
+
maps[2] = JBTokenMapping({localToken: tokenC, minGas: 200_000, remoteToken: bytes32(0), minBridgeAmount: 0});
|
|
168
|
+
|
|
169
|
+
address caller = makeAddr("caller");
|
|
170
|
+
vm.deal(caller, msgValue);
|
|
171
|
+
|
|
172
|
+
uint256 callerBalanceBefore = caller.balance;
|
|
173
|
+
|
|
174
|
+
vm.prank(caller);
|
|
175
|
+
sucker.mapTokens{value: msgValue}(maps);
|
|
176
|
+
|
|
177
|
+
uint256 callerBalanceAfter = caller.balance;
|
|
178
|
+
|
|
179
|
+
assertEq(
|
|
180
|
+
callerBalanceAfter, callerBalanceBefore - msgValue + expectedRemainder, "Dust remainder not refunded (fuzz)"
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _createTestSucker(uint256 _projectId, bytes32 salt) internal returns (L47TestSucker) {
|
|
185
|
+
L47TestSucker singleton = new L47TestSucker(
|
|
186
|
+
IJBDirectory(DIRECTORY),
|
|
187
|
+
IJBPermissions(PERMISSIONS),
|
|
188
|
+
IJBTokens(TOKENS),
|
|
189
|
+
JBAddToBalanceMode.MANUAL,
|
|
190
|
+
FORWARDER
|
|
191
|
+
);
|
|
192
|
+
vm.label(address(singleton), "SUCKER_SINGLETON");
|
|
193
|
+
|
|
194
|
+
L47TestSucker sucker = L47TestSucker(payable(address(LibClone.cloneDeterministic(address(singleton), salt))));
|
|
195
|
+
vm.label(address(sucker), "SUCKER");
|
|
196
|
+
sucker.initialize(_projectId);
|
|
197
|
+
|
|
198
|
+
return sucker;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
contract L47TestSucker is JBSucker {
|
|
203
|
+
constructor(
|
|
204
|
+
IJBDirectory directory,
|
|
205
|
+
IJBPermissions permissions,
|
|
206
|
+
IJBTokens tokens,
|
|
207
|
+
JBAddToBalanceMode addToBalanceMode,
|
|
208
|
+
address forwarder
|
|
209
|
+
)
|
|
210
|
+
JBSucker(directory, permissions, tokens, addToBalanceMode, forwarder)
|
|
211
|
+
{}
|
|
212
|
+
|
|
213
|
+
function _sendRootOverAMB(
|
|
214
|
+
uint256,
|
|
215
|
+
uint256,
|
|
216
|
+
address,
|
|
217
|
+
uint256,
|
|
218
|
+
JBRemoteToken memory,
|
|
219
|
+
JBMessageRoot memory
|
|
220
|
+
)
|
|
221
|
+
internal
|
|
222
|
+
override
|
|
223
|
+
{}
|
|
224
|
+
|
|
225
|
+
function _isRemotePeer(address sender) internal view override returns (bool valid) {
|
|
226
|
+
return sender == _toAddress(peer());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function peerChainId() external view virtual override returns (uint256) {
|
|
230
|
+
return block.chainid;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/// @notice Set the remote token mapping for testing.
|
|
234
|
+
function test_setRemoteToken(address localToken, JBRemoteToken memory remoteToken) external {
|
|
235
|
+
_remoteTokenFor[localToken] = remoteToken;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// @notice Set the outbox tree count for testing (simulates unsent claims).
|
|
239
|
+
function test_setOutboxTreeCount(address token, uint256 count) external {
|
|
240
|
+
_outboxOf[token].tree.count = count;
|
|
241
|
+
}
|
|
242
|
+
}
|