@bananapus/suckers-v6 0.0.48 → 0.0.49
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/package.json +3 -3
- package/src/JBArbitrumSucker.sol +4 -1
- package/src/JBCeloSucker.sol +2 -0
- package/src/JBOptimismSucker.sol +2 -0
- package/src/JBSucker.sol +35 -1
- package/src/JBSuckerRegistry.sol +76 -57
- package/src/JBSwapCCIPSucker.sol +33 -96
- package/src/interfaces/IJBSucker.sol +11 -6
- package/src/interfaces/IJBSuckerRegistry.sol +6 -3
- package/src/libraries/CCIPHelper.sol +64 -64
- package/src/libraries/JBCCIPLib.sol +18 -0
- package/src/libraries/JBSuckerLib.sol +157 -161
- package/src/libraries/JBSwapPoolLib.sol +268 -268
- package/src/structs/PeerValueScratch.sol +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bananapus/suckers-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.49",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@arbitrum/nitro-contracts": "3.2.0",
|
|
33
|
-
"@bananapus/core-v6": "^0.0.
|
|
34
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
33
|
+
"@bananapus/core-v6": "^0.0.57",
|
|
34
|
+
"@bananapus/permission-ids-v6": "^0.0.26",
|
|
35
35
|
"@chainlink/contracts-ccip": "1.6.4",
|
|
36
36
|
"@chainlink/local": "0.2.7",
|
|
37
37
|
"@openzeppelin/contracts": "5.6.1",
|
package/src/JBArbitrumSucker.sol
CHANGED
|
@@ -255,12 +255,13 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
255
255
|
// If the token is an ERC-20, bridge it to the peer.
|
|
256
256
|
// If the amount is `0` then we do not need to bridge any ERC20.
|
|
257
257
|
if (token != JBConstants.NATIVE_TOKEN && amount != 0) {
|
|
258
|
+
address gateway;
|
|
258
259
|
uint256 tokenTransportCost;
|
|
259
260
|
uint256 maxSubmissionCostERC20;
|
|
260
261
|
{
|
|
261
262
|
// Get the exact calldata length the gateway will create for the retryable ticket.
|
|
262
263
|
// The Arbitrum Inbox validates maxSubmissionCost against this actual payload, not the user data.
|
|
263
|
-
|
|
264
|
+
gateway = GATEWAYROUTER.getGateway(token);
|
|
264
265
|
uint256 outboundCalldataLength =
|
|
265
266
|
IL1ArbitrumGateway(gateway)
|
|
266
267
|
.getOutboundCalldata({
|
|
@@ -299,6 +300,8 @@ contract JBArbitrumSucker is JBSucker, IJBArbitrumSucker {
|
|
|
299
300
|
gasPriceBid: maxFeePerGas,
|
|
300
301
|
data: bytes(abi.encode(maxSubmissionCostERC20, bytes("")))
|
|
301
302
|
});
|
|
303
|
+
|
|
304
|
+
SafeERC20.forceApprove({token: IERC20(token), spender: gateway, value: 0});
|
|
302
305
|
} else {
|
|
303
306
|
// Ensure we bridge enough for gas costs on L2 side
|
|
304
307
|
if (transportPayment < callTransportCost) {
|
package/src/JBCeloSucker.sol
CHANGED
|
@@ -164,6 +164,8 @@ contract JBCeloSucker is JBOptimismSucker {
|
|
|
164
164
|
minGasLimit: remoteToken.minGas,
|
|
165
165
|
extraData: bytes("")
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
SafeERC20.forceApprove({token: IERC20(bridgeToken), spender: address(OPBRIDGE), value: 0});
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
// Send the messenger message with nativeValue = 0.
|
package/src/JBOptimismSucker.sol
CHANGED
|
@@ -126,6 +126,8 @@ contract JBOptimismSucker is JBSucker, IJBOptimismSucker {
|
|
|
126
126
|
minGasLimit: remoteToken.minGas,
|
|
127
127
|
extraData: bytes("")
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
SafeERC20.forceApprove({token: IERC20(token), spender: address(OPBRIDGE), value: 0});
|
|
129
131
|
} else {
|
|
130
132
|
// Otherwise, the token is the native token, and the amount will be sent as `msg.value`.
|
|
131
133
|
nativeValue = amount;
|
package/src/JBSucker.sol
CHANGED
|
@@ -83,6 +83,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
83
83
|
error JBSucker_NoRetainedToRemoteFee(address account);
|
|
84
84
|
error JBSucker_NoRetainedTransportPaymentRefund(address account);
|
|
85
85
|
error JBSucker_RefundFailed(address beneficiary, uint256 amount);
|
|
86
|
+
error JBSucker_RemoteTokenAlreadyMapped(bytes32 remoteToken, address localToken);
|
|
86
87
|
error JBSucker_TokenAlreadyMapped(address localToken, bytes32 mappedTo);
|
|
87
88
|
error JBSucker_TokenHasInvalidEmergencyHatchState(address token);
|
|
88
89
|
error JBSucker_TokenNotMapped(address token);
|
|
@@ -175,6 +176,14 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
175
176
|
/// @custom:param token The local terminal token to get the inbox for.
|
|
176
177
|
mapping(address token => JBInboxTreeRoot root) internal _inboxOf;
|
|
177
178
|
|
|
179
|
+
/// @notice The local token that has reserved each remote token address in this sucker.
|
|
180
|
+
/// @dev Inbound roots are keyed by `root.token` on the destination chain. Within a single sucker, allowing two
|
|
181
|
+
/// local tokens to send roots to the same remote token would give them independent source nonces but one shared
|
|
182
|
+
/// destination inbox, causing stale rejections or root overwrites. Each sucker keeps its own reservation map, so
|
|
183
|
+
/// separate bridge lanes for the same asset pair can coexist.
|
|
184
|
+
/// @custom:param remoteToken The remote terminal token address encoded as bytes32.
|
|
185
|
+
mapping(bytes32 remoteToken => address localToken) internal _localTokenForRemoteToken;
|
|
186
|
+
|
|
178
187
|
/// @notice The outbox merkle tree for a given token.
|
|
179
188
|
/// @custom:param token The local terminal token to get the outbox for.
|
|
180
189
|
mapping(address token => JBOutboxTree) internal _outboxOf;
|
|
@@ -1031,6 +1040,10 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
1031
1040
|
/// after outbox activity, the same local funds could be claimed against two different remote tokens. A
|
|
1032
1041
|
/// misconfigured mapping therefore requires deploying a new sucker. Re-enabling a previously disabled mapping
|
|
1033
1042
|
/// (back to the same remote token) is supported.
|
|
1043
|
+
/// @dev Remote tokens are also unique per local token within this sucker. The source side keeps separate
|
|
1044
|
+
/// outboxes/nonces per local token, but the destination side stores roots under the remote token address. Sharing
|
|
1045
|
+
/// one remote token across multiple local tokens in the same sucker would merge those inboxes on the destination
|
|
1046
|
+
/// chain. Separate suckers can still map the same local/remote token pair, letting users choose a bridge lane.
|
|
1034
1047
|
/// @param map The local and remote terminal token addresses to map, and minimum amount/gas limits for bridging
|
|
1035
1048
|
/// them.
|
|
1036
1049
|
/// @param transportPaymentValue The amount of `msg.value` to send for the token mapping.
|
|
@@ -1068,6 +1081,16 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
1068
1081
|
revert JBSucker_TokenAlreadyMapped({localToken: token, mappedTo: currentMapping.addr});
|
|
1069
1082
|
}
|
|
1070
1083
|
|
|
1084
|
+
// A remote token can back only one local token's outbox in this sucker. Otherwise two independent source
|
|
1085
|
+
// nonces would race into the same destination inbox key (`root.token`), making one token's root stale or
|
|
1086
|
+
// overwriting the other. Other suckers have separate inbox/outbox storage and are unaffected.
|
|
1087
|
+
if (map.remoteToken != bytes32(0)) {
|
|
1088
|
+
address mappedLocalToken = _localTokenForRemoteToken[map.remoteToken];
|
|
1089
|
+
if (mappedLocalToken != address(0) && mappedLocalToken != token) {
|
|
1090
|
+
revert JBSucker_RemoteTokenAlreadyMapped({remoteToken: map.remoteToken, localToken: mappedLocalToken});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1071
1094
|
// No inbox guard needed here. Token remapping only affects the outbound (sending) path —
|
|
1072
1095
|
// it changes where tokens get bridged TO. Existing inbox claims are resolved against the inbox merkle
|
|
1073
1096
|
// tree keyed by the local token address. Changing the remote token doesn't invalidate those claims
|
|
@@ -1085,6 +1108,17 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
1085
1108
|
_sendRoot({transportPayment: transportPaymentValue, token: token, remoteToken: currentMapping});
|
|
1086
1109
|
}
|
|
1087
1110
|
|
|
1111
|
+
// Update the reverse reservation if an unused local token is being remapped to a new remote token.
|
|
1112
|
+
if (
|
|
1113
|
+
map.remoteToken != bytes32(0) && currentMapping.addr != bytes32(0) && currentMapping.addr != map.remoteToken
|
|
1114
|
+
&& _localTokenForRemoteToken[currentMapping.addr] == token
|
|
1115
|
+
) {
|
|
1116
|
+
delete _localTokenForRemoteToken[currentMapping.addr];
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
bytes32 remoteToken = map.remoteToken == bytes32(0) ? currentMapping.addr : map.remoteToken;
|
|
1120
|
+
if (remoteToken != bytes32(0)) _localTokenForRemoteToken[remoteToken] = token;
|
|
1121
|
+
|
|
1088
1122
|
// Update the token mapping.
|
|
1089
1123
|
_remoteTokenFor[token] = JBRemoteToken({
|
|
1090
1124
|
enabled: map.remoteToken != bytes32(0),
|
|
@@ -1092,7 +1126,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
1092
1126
|
minGas: map.minGas,
|
|
1093
1127
|
// This is done so that a token can be disabled and then enabled again
|
|
1094
1128
|
// while ensuring the remoteToken never changes (unless it hasn't been used yet)
|
|
1095
|
-
addr:
|
|
1129
|
+
addr: remoteToken
|
|
1096
1130
|
});
|
|
1097
1131
|
}
|
|
1098
1132
|
|
package/src/JBSuckerRegistry.sol
CHANGED
|
@@ -18,6 +18,7 @@ import {IJBSuckerDeployer} from "./interfaces/IJBSuckerDeployer.sol";
|
|
|
18
18
|
import {IJBSuckerRegistry} from "./interfaces/IJBSuckerRegistry.sol";
|
|
19
19
|
import {JBSuckerDeployerConfig} from "./structs/JBSuckerDeployerConfig.sol";
|
|
20
20
|
import {JBSuckersPair} from "./structs/JBSuckersPair.sol";
|
|
21
|
+
import {PeerValueScratch} from "./structs/PeerValueScratch.sol";
|
|
21
22
|
|
|
22
23
|
/// @notice The canonical registry that deploys, tracks, and governs cross-chain suckers for Juicebox projects. It
|
|
23
24
|
/// maintains an allowlist of approved deployer contracts, allows multiple active suckers per peer chain for bridge
|
|
@@ -210,10 +211,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
210
211
|
|
|
211
212
|
// Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
|
|
212
213
|
// so a linear scan is cheaper than a mapping.
|
|
213
|
-
|
|
214
|
-
uint256[] memory maxBalances = new uint256[](len);
|
|
215
|
-
bool[] memory hasActiveValue = new bool[](len);
|
|
216
|
-
uint256 chainCount;
|
|
214
|
+
PeerValueScratch memory scratch = _peerValueScratch(len);
|
|
217
215
|
|
|
218
216
|
for (uint256 i; i < len;) {
|
|
219
217
|
(, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
|
|
@@ -223,13 +221,11 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
223
221
|
JBDenominatedAmount memory amt
|
|
224
222
|
) {
|
|
225
223
|
uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
|
|
226
|
-
chainCount = _recordPeerValue({
|
|
227
|
-
|
|
228
|
-
values: maxBalances,
|
|
229
|
-
hasActiveValue: hasActiveValue,
|
|
230
|
-
chainCount: chainCount,
|
|
224
|
+
scratch.chainCount = _recordPeerValue({
|
|
225
|
+
scratch: scratch,
|
|
231
226
|
chainId: chainId,
|
|
232
227
|
value: amt.value,
|
|
228
|
+
snapshotTimestamp: _snapshotTimestampOf(allSuckers[i]),
|
|
233
229
|
isActive: val == _SUCKER_EXISTS
|
|
234
230
|
});
|
|
235
231
|
} catch {}
|
|
@@ -239,9 +235,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
239
235
|
}
|
|
240
236
|
}
|
|
241
237
|
|
|
242
|
-
// Sum the per-chain
|
|
243
|
-
for (uint256 k; k < chainCount;) {
|
|
244
|
-
balance +=
|
|
238
|
+
// Sum the per-chain selected values.
|
|
239
|
+
for (uint256 k; k < scratch.chainCount;) {
|
|
240
|
+
balance += scratch.values[k];
|
|
245
241
|
unchecked {
|
|
246
242
|
++k;
|
|
247
243
|
}
|
|
@@ -271,10 +267,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
271
267
|
|
|
272
268
|
// Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
|
|
273
269
|
// so a linear scan is cheaper than a mapping.
|
|
274
|
-
|
|
275
|
-
uint256[] memory maxSurplus = new uint256[](len);
|
|
276
|
-
bool[] memory hasActiveValue = new bool[](len);
|
|
277
|
-
uint256 chainCount;
|
|
270
|
+
PeerValueScratch memory scratch = _peerValueScratch(len);
|
|
278
271
|
|
|
279
272
|
for (uint256 i; i < len;) {
|
|
280
273
|
(, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
|
|
@@ -284,13 +277,11 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
284
277
|
JBDenominatedAmount memory amt
|
|
285
278
|
) {
|
|
286
279
|
uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
|
|
287
|
-
chainCount = _recordPeerValue({
|
|
288
|
-
|
|
289
|
-
values: maxSurplus,
|
|
290
|
-
hasActiveValue: hasActiveValue,
|
|
291
|
-
chainCount: chainCount,
|
|
280
|
+
scratch.chainCount = _recordPeerValue({
|
|
281
|
+
scratch: scratch,
|
|
292
282
|
chainId: chainId,
|
|
293
283
|
value: amt.value,
|
|
284
|
+
snapshotTimestamp: _snapshotTimestampOf(allSuckers[i]),
|
|
294
285
|
isActive: val == _SUCKER_EXISTS
|
|
295
286
|
});
|
|
296
287
|
} catch {}
|
|
@@ -300,9 +291,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
300
291
|
}
|
|
301
292
|
}
|
|
302
293
|
|
|
303
|
-
// Sum the per-chain
|
|
304
|
-
for (uint256 k; k < chainCount;) {
|
|
305
|
-
surplus +=
|
|
294
|
+
// Sum the per-chain selected values.
|
|
295
|
+
for (uint256 k; k < scratch.chainCount;) {
|
|
296
|
+
surplus += scratch.values[k];
|
|
306
297
|
unchecked {
|
|
307
298
|
++k;
|
|
308
299
|
}
|
|
@@ -321,10 +312,7 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
321
312
|
|
|
322
313
|
// Per-chain dedup arrays. The number of suckers per project is small (typically 1-5),
|
|
323
314
|
// so a linear scan is cheaper than a mapping.
|
|
324
|
-
|
|
325
|
-
uint256[] memory maxSupply = new uint256[](len);
|
|
326
|
-
bool[] memory hasActiveValue = new bool[](len);
|
|
327
|
-
uint256 chainCount;
|
|
315
|
+
PeerValueScratch memory scratch = _peerValueScratch(len);
|
|
328
316
|
|
|
329
317
|
for (uint256 i; i < len;) {
|
|
330
318
|
(, uint256 val) = _suckersOf[projectId].tryGet(allSuckers[i]);
|
|
@@ -332,13 +320,11 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
332
320
|
if (val == _SUCKER_EXISTS || val == _SUCKER_DEPRECATED) {
|
|
333
321
|
try IJBSucker(allSuckers[i]).peerChainTotalSupply() returns (uint256 supply) {
|
|
334
322
|
uint256 chainId = IJBSucker(allSuckers[i]).peerChainId();
|
|
335
|
-
chainCount = _recordPeerValue({
|
|
336
|
-
|
|
337
|
-
values: maxSupply,
|
|
338
|
-
hasActiveValue: hasActiveValue,
|
|
339
|
-
chainCount: chainCount,
|
|
323
|
+
scratch.chainCount = _recordPeerValue({
|
|
324
|
+
scratch: scratch,
|
|
340
325
|
chainId: chainId,
|
|
341
326
|
value: supply,
|
|
327
|
+
snapshotTimestamp: _snapshotTimestampOf(allSuckers[i]),
|
|
342
328
|
isActive: val == _SUCKER_EXISTS
|
|
343
329
|
});
|
|
344
330
|
} catch {}
|
|
@@ -348,9 +334,9 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
348
334
|
}
|
|
349
335
|
}
|
|
350
336
|
|
|
351
|
-
// Sum the per-chain
|
|
352
|
-
for (uint256 k; k < chainCount;) {
|
|
353
|
-
totalSupply +=
|
|
337
|
+
// Sum the per-chain selected values.
|
|
338
|
+
for (uint256 k; k < scratch.chainCount;) {
|
|
339
|
+
totalSupply += scratch.values[k];
|
|
354
340
|
unchecked {
|
|
355
341
|
++k;
|
|
356
342
|
}
|
|
@@ -378,58 +364,91 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
378
364
|
return ERC2771Context._msgSender();
|
|
379
365
|
}
|
|
380
366
|
|
|
367
|
+
/// @notice Allocates scratch arrays used to collapse many suckers into one aggregate value per peer chain.
|
|
368
|
+
/// @dev `len` is the number of suckers being scanned, which is the maximum possible number of distinct peer
|
|
369
|
+
/// chains. `chainCount` starts at zero and is incremented as new peer chains are discovered.
|
|
370
|
+
/// @param len The maximum number of peer-chain entries the aggregation can need.
|
|
371
|
+
/// @return scratch Empty scratch space sized for the current aggregation pass.
|
|
372
|
+
function _peerValueScratch(uint256 len) internal pure returns (PeerValueScratch memory scratch) {
|
|
373
|
+
// Allocate each parallel array up front so `_recordPeerValue` can update by index without resizing memory.
|
|
374
|
+
scratch.chainIds = new uint256[](len);
|
|
375
|
+
scratch.values = new uint256[](len);
|
|
376
|
+
scratch.snapshotTimestamps = new uint256[](len);
|
|
377
|
+
scratch.hasActiveValue = new bool[](len);
|
|
378
|
+
}
|
|
379
|
+
|
|
381
380
|
/// @notice Records a project-scoped peer-chain aggregate value.
|
|
382
381
|
/// @dev Callers pass scratch arrays sized from `_suckersOf[projectId].keys()`, so entries are already scoped to
|
|
383
382
|
/// the project being aggregated. For each peer chain, active suckers replace deprecated suckers; deprecated
|
|
384
383
|
/// values are only used as a migration fallback when no active sucker has reported for that chain.
|
|
385
|
-
/// @param
|
|
386
|
-
/// @param values The aggregate values recorded so far.
|
|
387
|
-
/// @param hasActiveValue Whether the recorded value for each index came from an active sucker.
|
|
388
|
-
/// @param chainCount The number of populated chain entries.
|
|
384
|
+
/// @param scratch The per-chain aggregate values and freshness keys recorded so far.
|
|
389
385
|
/// @param chainId The peer-chain id to record.
|
|
390
386
|
/// @param value The value to record.
|
|
387
|
+
/// @param snapshotTimestamp The snapshot freshness key to record.
|
|
391
388
|
/// @param isActive Whether the value came from an active sucker.
|
|
392
389
|
/// @return The updated number of populated chain entries.
|
|
393
390
|
function _recordPeerValue(
|
|
394
|
-
|
|
395
|
-
uint256[] memory values,
|
|
396
|
-
bool[] memory hasActiveValue,
|
|
397
|
-
uint256 chainCount,
|
|
391
|
+
PeerValueScratch memory scratch,
|
|
398
392
|
uint256 chainId,
|
|
399
393
|
uint256 value,
|
|
394
|
+
uint256 snapshotTimestamp,
|
|
400
395
|
bool isActive
|
|
401
396
|
)
|
|
402
397
|
internal
|
|
403
398
|
pure
|
|
404
399
|
returns (uint256)
|
|
405
400
|
{
|
|
406
|
-
for (uint256 j; j < chainCount;) {
|
|
407
|
-
if (chainIds[j] == chainId) {
|
|
401
|
+
for (uint256 j; j < scratch.chainCount;) {
|
|
402
|
+
if (scratch.chainIds[j] == chainId) {
|
|
408
403
|
// Each sucker caches the entire remote chain's state (not a per-sucker share), so multiple
|
|
409
|
-
// suckers targeting the same chain report redundant snapshots.
|
|
410
|
-
//
|
|
404
|
+
// suckers targeting the same chain report redundant snapshots. Prefer the freshest source-chain
|
|
405
|
+
// snapshot; use MAX only as a same-freshness tie-breaker or deprecated fallback.
|
|
411
406
|
if (isActive) {
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
407
|
+
if (
|
|
408
|
+
!scratch.hasActiveValue[j] || snapshotTimestamp > scratch.snapshotTimestamps[j]
|
|
409
|
+
|| (snapshotTimestamp == scratch.snapshotTimestamps[j] && value > scratch.values[j])
|
|
410
|
+
) {
|
|
411
|
+
scratch.values[j] = value;
|
|
412
|
+
scratch.snapshotTimestamps[j] = snapshotTimestamp;
|
|
413
|
+
}
|
|
414
|
+
scratch.hasActiveValue[j] = true;
|
|
415
|
+
} else if (
|
|
416
|
+
!scratch.hasActiveValue[j]
|
|
417
|
+
&& (snapshotTimestamp > scratch.snapshotTimestamps[j]
|
|
418
|
+
|| (snapshotTimestamp == scratch.snapshotTimestamps[j] && value > scratch.values[j]))
|
|
419
|
+
) {
|
|
415
420
|
// Deprecated suckers only fill the gap until an active value for this chain has been observed.
|
|
416
|
-
values[j] = value;
|
|
421
|
+
scratch.values[j] = value;
|
|
422
|
+
scratch.snapshotTimestamps[j] = snapshotTimestamp;
|
|
417
423
|
}
|
|
418
|
-
return chainCount;
|
|
424
|
+
return scratch.chainCount;
|
|
419
425
|
}
|
|
420
426
|
unchecked {
|
|
421
427
|
++j;
|
|
422
428
|
}
|
|
423
429
|
}
|
|
424
430
|
|
|
425
|
-
chainIds[chainCount] = chainId;
|
|
426
|
-
values[chainCount] = value;
|
|
427
|
-
|
|
431
|
+
scratch.chainIds[scratch.chainCount] = chainId;
|
|
432
|
+
scratch.values[scratch.chainCount] = value;
|
|
433
|
+
scratch.snapshotTimestamps[scratch.chainCount] = snapshotTimestamp;
|
|
434
|
+
scratch.hasActiveValue[scratch.chainCount] = isActive;
|
|
428
435
|
unchecked {
|
|
429
|
-
return chainCount + 1;
|
|
436
|
+
return scratch.chainCount + 1;
|
|
430
437
|
}
|
|
431
438
|
}
|
|
432
439
|
|
|
440
|
+
/// @notice Reads a sucker's snapshot timestamp, returning zero if the sucker does not expose it.
|
|
441
|
+
/// @dev Older or malformed suckers should not brick aggregate registry views. A zero timestamp makes their value
|
|
442
|
+
/// lose to any successful fresh read for the same peer chain.
|
|
443
|
+
/// @param sucker The sucker to query.
|
|
444
|
+
/// @return timestamp The reported snapshot timestamp, or zero if the call fails.
|
|
445
|
+
function _snapshotTimestampOf(address sucker) internal view returns (uint256 timestamp) {
|
|
446
|
+
// Keep aggregate views available even if one registered sucker has a stale ABI or reverts unexpectedly.
|
|
447
|
+
try IJBSucker(sucker).snapshotTimestamp() returns (uint256 result) {
|
|
448
|
+
timestamp = result;
|
|
449
|
+
} catch {}
|
|
450
|
+
}
|
|
451
|
+
|
|
433
452
|
//*********************************************************************//
|
|
434
453
|
// ---------------------- public transactions ----------------------- //
|
|
435
454
|
//*********************************************************************//
|
package/src/JBSwapCCIPSucker.sol
CHANGED
|
@@ -175,19 +175,15 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
175
175
|
/// @custom:param nonce The CCIP nonce identifying the batch.
|
|
176
176
|
mapping(address token => mapping(uint64 nonce => ConversionRate)) internal _conversionRateOf;
|
|
177
177
|
|
|
178
|
-
/// @notice Highest nonce received so far per token. Used as upper bound for nonce iteration.
|
|
179
|
-
/// @custom:param token The local token address.
|
|
180
|
-
mapping(address token => uint64) internal _highestReceivedNonce;
|
|
181
|
-
|
|
182
178
|
/// @notice Count of populated batch nonces per token. Appended exactly once per batch in
|
|
183
179
|
/// `ccipReceive`, so it equals the number of received batches independent of CCIP ordering.
|
|
184
180
|
/// @custom:param token The local token address.
|
|
185
181
|
mapping(address token => uint64) internal _populatedNonceCount;
|
|
186
182
|
|
|
187
|
-
/// @notice Populated batch nonces per token, indexed by insertion order.
|
|
188
|
-
///
|
|
189
|
-
///
|
|
190
|
-
///
|
|
183
|
+
/// @notice Populated batch nonces per token, indexed by insertion order.
|
|
184
|
+
/// @dev `_findNonceForLeafIndex` walks this list directly. That bounds lookup by the number of
|
|
185
|
+
/// received batches, not by the highest nonce, so sparse or out-of-order CCIP delivery cannot
|
|
186
|
+
/// force the claim path to scan empty nonce slots.
|
|
191
187
|
/// @custom:param token The local token address.
|
|
192
188
|
/// @custom:param index The insertion index in [0, _populatedNonceCount[token]).
|
|
193
189
|
mapping(address token => mapping(uint64 index => uint64 nonce)) internal _populatedNonceByIndex;
|
|
@@ -201,6 +197,10 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
201
197
|
// ------------------- transient stored properties ------------------- //
|
|
202
198
|
//*********************************************************************//
|
|
203
199
|
|
|
200
|
+
/// @dev Reentrancy guard for the initial `ccipReceive` swap. Prevents claims from consuming newly received
|
|
201
|
+
/// swap output before the batch's conversion rate has been recorded.
|
|
202
|
+
bool transient _ccipReceiveSwapLocked;
|
|
203
|
+
|
|
204
204
|
/// @notice Leaf index + 1 of the claim currently in progress (set by the `claim` override).
|
|
205
205
|
/// @dev Transient storage — auto-resets to 0 each transaction, saving ~9,800 gas per claim vs SSTORE.
|
|
206
206
|
/// Value 0 means no active claim (bypass scaling); non-zero means leafIndex = value - 1.
|
|
@@ -344,13 +344,16 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
344
344
|
// Wrapped in try-catch so a swap failure doesn't revert the entire CCIP message
|
|
345
345
|
// (which would leave tokens stuck in the OffRamp). On failure, bridge tokens are
|
|
346
346
|
// stored for later retry via `retrySwap` (written below, after nonce validation).
|
|
347
|
+
_ccipReceiveSwapLocked = true;
|
|
347
348
|
try this.executeSwapExternal({
|
|
348
349
|
tokenIn: deliveredToken, tokenOut: localToken, amount: deliveredAmount
|
|
349
350
|
}) returns (
|
|
350
351
|
uint256 swapped
|
|
351
352
|
) {
|
|
353
|
+
_ccipReceiveSwapLocked = false;
|
|
352
354
|
localAmount = swapped;
|
|
353
355
|
} catch {
|
|
356
|
+
_ccipReceiveSwapLocked = false;
|
|
354
357
|
swapFailed = true;
|
|
355
358
|
// localAmount stays 0 — pendingSwapOf and conversion rate are written
|
|
356
359
|
// below, after fromRemote validates the nonce.
|
|
@@ -401,14 +404,6 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
401
404
|
unchecked {
|
|
402
405
|
_populatedNonceCount[localToken] = priorCount + 1;
|
|
403
406
|
}
|
|
404
|
-
|
|
405
|
-
// Track the highest nonce ever observed for this token. Read by
|
|
406
|
-
// `_findNonceForLeafIndex` as the binary-search upper bound. Out-of-order
|
|
407
|
-
// delivery keeps this monotonic — we only advance it when the new nonce is
|
|
408
|
-
// strictly higher than the prior maximum.
|
|
409
|
-
if (nonce > _highestReceivedNonce[localToken]) {
|
|
410
|
-
_highestReceivedNonce[localToken] = nonce;
|
|
411
|
-
}
|
|
412
407
|
}
|
|
413
408
|
|
|
414
409
|
// Store pendingSwapOf for failed swaps now that nonce is validated.
|
|
@@ -529,7 +524,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
529
524
|
/// @param claimData The claim data containing the leaf and proof.
|
|
530
525
|
function claim(JBClaim calldata claimData) public override {
|
|
531
526
|
// Block claims during retrySwap to prevent zero-backed minting via reentrancy.
|
|
532
|
-
if (_retrySwapLocked) revert JBSwapCCIPSucker_SwapPending(0);
|
|
527
|
+
if (_retrySwapLocked || _ccipReceiveSwapLocked) revert JBSwapCCIPSucker_SwapPending(0);
|
|
533
528
|
_currentClaimLeafIndex = claimData.leaf.index + 1;
|
|
534
529
|
super.claim(claimData);
|
|
535
530
|
// Clear stale transient context to prevent leaking into same-tx emergency exits.
|
|
@@ -703,94 +698,36 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
703
698
|
// ----------------------- internal views ---------------------------- //
|
|
704
699
|
//*********************************************************************//
|
|
705
700
|
|
|
706
|
-
/// @notice Find the nonce whose batch contains the given leaf index.
|
|
707
|
-
/// @dev
|
|
708
|
-
///
|
|
709
|
-
///
|
|
710
|
-
///
|
|
711
|
-
/// Binary search exploits that monotonicity for O(log N) lookup.
|
|
712
|
-
/// @dev Gap handling: when the midpoint slot is empty (CCIP out-of-order delivery or sparse
|
|
713
|
-
/// attacker writes), the fallback walks `_populatedNonceByIndex` — the list of every nonce
|
|
714
|
-
/// actually populated. That bounds the worst case at `O(K)` SLOADs, where `K` is the number
|
|
715
|
-
/// of received batches, regardless of how sparse the populated set is inside `[1, maxNonce]`.
|
|
716
|
-
/// The empty-slot scans that drove `O(N)` under the prior linear fallback are eliminated.
|
|
701
|
+
/// @notice Find the received nonce whose batch contains the given leaf index.
|
|
702
|
+
/// @dev Walks `_populatedNonceByIndex` instead of `[1, highestNonce]`. The populated list is the
|
|
703
|
+
/// only set that can contain a claimable batch, and it stays compact even when CCIP delivers
|
|
704
|
+
/// nonce 10 before nonce 2. This keeps lookup O(K) where K is received batches, avoids sparse
|
|
705
|
+
/// empty-slot scans, and keeps the deployable bytecode below the EIP-170 size limit.
|
|
717
706
|
/// @param token The local token address.
|
|
718
707
|
/// @param leafIndex The leaf index from the claim.
|
|
719
708
|
/// @return The nonce of the batch containing this leaf, or 0 if no batches have been recorded.
|
|
720
709
|
function _findNonceForLeafIndex(address token, uint256 leafIndex) internal view returns (uint64) {
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
uint64
|
|
724
|
-
if (
|
|
725
|
-
|
|
726
|
-
//
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
// Wrap arithmetic in `unchecked` — `lo` and `hi` are always in `[1, maxNonce]` and the
|
|
731
|
-
// edge-guards (`mid == lo` / `mid == hi` breaks) prevent the only ways `mid - 1` or
|
|
732
|
-
// `mid + 1` could over/underflow. Skipping the compiler checks shaves enough bytecode to
|
|
733
|
-
// stay under EIP-170 without changing semantics.
|
|
710
|
+
// No populated batches for this token means there is no conversion rate to apply. Preserve
|
|
711
|
+
// nonce 0 as the "unbatched" sentinel used by `_addToBalance`'s non-claim path.
|
|
712
|
+
uint64 count = _populatedNonceCount[token];
|
|
713
|
+
if (count == 0) return 0;
|
|
714
|
+
|
|
715
|
+
// Walk only nonces that actually received a batch. The array is insertion-ordered, not
|
|
716
|
+
// sorted, because CCIP can deliver batches out of nonce order; each entry still points to a
|
|
717
|
+
// self-contained `[batchStart, batchEnd)` range written before the append.
|
|
734
718
|
unchecked {
|
|
735
|
-
|
|
736
|
-
uint64
|
|
737
|
-
|
|
738
|
-
// write guard in `ccipReceive`); a real batch always has `batchEnd > batchStart`.
|
|
739
|
-
uint256 end = _batchEndOf[token][mid];
|
|
740
|
-
|
|
741
|
-
// Empty midpoint from out-of-order CCIP delivery (the sender minted a higher nonce
|
|
742
|
-
// than the inbox has yet received, leaving holes in `[1, maxNonce]`) or a sparse
|
|
743
|
-
// pattern an attacker assembled by inflating `_highestReceivedNonce` without
|
|
744
|
-
// populating intermediate slots. The earlier linear `[lo, hi]` scan walked every
|
|
745
|
-
// empty slot, so worst-case cost was `O(maxNonce)` SLOADs — that's the residual
|
|
746
|
-
// the audit flagged. Walk `_populatedNonceByIndex` instead: it's `K` entries (one
|
|
747
|
-
// per received batch) and contains exactly the slots a real batch can cover.
|
|
748
|
-
if (end == 0) {
|
|
749
|
-
// Number of populated batch slots for this token — equal to the array length.
|
|
750
|
-
// Maintained by `ccipReceive`'s append (one SSTORE per first-time receive).
|
|
751
|
-
uint64 count = _populatedNonceCount[token];
|
|
752
|
-
|
|
753
|
-
// Linear walk over the populated set. Bounded by `count`, not `maxNonce`, so
|
|
754
|
-
// sparse adversarial patterns can't inflate this loop with empty slots.
|
|
755
|
-
for (uint64 i; i < count; i++) {
|
|
756
|
-
// Insertion-ordered nonce at index `i`. May be any value in `[1, maxNonce]`
|
|
757
|
-
// because CCIP delivery is out-of-order; the array is not sorted by nonce.
|
|
758
|
-
uint64 n = _populatedNonceByIndex[token][i];
|
|
759
|
-
|
|
760
|
-
// Read end of this batch's leaf range. Always `> 0` here — we only push to
|
|
761
|
-
// `_populatedNonceByIndex` after the corresponding `_batchEndOf` write.
|
|
762
|
-
uint256 nEnd = _batchEndOf[token][n];
|
|
763
|
-
|
|
764
|
-
// Coverage test: each batch's `[batchStart, batchEnd)` is non-overlapping
|
|
765
|
-
// across populated nonces, so a hit is unique. The `[lo, hi]` window from
|
|
766
|
-
// the binary search isn't applied — the binary-search invariant guarantees
|
|
767
|
-
// the leaf can only live in a populated nonce, so an out-of-window match
|
|
768
|
-
// would mean the binary search was already wrong; falling through to the
|
|
769
|
-
// next iteration costs less than the extra two compares per iteration.
|
|
770
|
-
if (leafIndex >= _batchStartOf[token][n] && leafIndex < nEnd) return n;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// No populated nonce contains `leafIndex` for this token. Break out of the
|
|
774
|
-
// outer binary-search loop and fall through to the shared revert below.
|
|
775
|
-
break;
|
|
776
|
-
}
|
|
719
|
+
for (uint64 i; i < count; i++) {
|
|
720
|
+
uint64 nonce = _populatedNonceByIndex[token][i];
|
|
721
|
+
uint256 end = _batchEndOf[token][nonce];
|
|
777
722
|
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
|
|
781
|
-
if (leafIndex < start) {
|
|
782
|
-
if (mid == lo) break; // Guard against `mid - 1` underflow at the lower edge.
|
|
783
|
-
hi = mid - 1;
|
|
784
|
-
} else if (leafIndex >= end) {
|
|
785
|
-
if (mid == hi) break; // Mirror guard at the upper edge.
|
|
786
|
-
lo = mid + 1;
|
|
787
|
-
} else {
|
|
788
|
-
return mid; // `start <= leafIndex < end`: leaf is inside this batch.
|
|
789
|
-
}
|
|
723
|
+
// Ranges are non-overlapping across populated nonces. The first hit is therefore
|
|
724
|
+
// the unique conversion-rate batch for this claim leaf.
|
|
725
|
+
if (leafIndex >= _batchStartOf[token][nonce] && leafIndex < end) return nonce;
|
|
790
726
|
}
|
|
791
727
|
}
|
|
792
728
|
|
|
793
|
-
//
|
|
729
|
+
// Batches exist for the token, but none cover this leaf index; surface the same error used
|
|
730
|
+
// before the compact populated-nonce index was introduced.
|
|
794
731
|
revert JBSwapCCIPSucker_BatchNotReceived({nonce: 0});
|
|
795
732
|
}
|
|
796
733
|
}
|
|
@@ -128,12 +128,6 @@ interface IJBSucker is IERC165 {
|
|
|
128
128
|
/// @return chainId The remote chain ID.
|
|
129
129
|
function peerChainId() external view returns (uint256 chainId);
|
|
130
130
|
|
|
131
|
-
/// @notice The last known total token supply on the peer chain, updated each time a bridge message is received.
|
|
132
|
-
/// @dev Used by data hooks to compute `effectiveTotalSupply = localSupply + sum(peerChainTotalSupply)` across all
|
|
133
|
-
/// suckers, preventing cash out tax bypass on chains where a holder dominates the local supply.
|
|
134
|
-
/// @return The peer chain's total supply.
|
|
135
|
-
function peerChainTotalSupply() external view returns (uint256);
|
|
136
|
-
|
|
137
131
|
/// @notice The aggregate peer chain balance, normalized to a desired currency and decimal precision using JBPrices.
|
|
138
132
|
/// @dev The balance is stored as ETH-denominated (18 decimals) and converted to the requested currency/decimals
|
|
139
133
|
/// using the local JBPrices oracle.
|
|
@@ -150,6 +144,12 @@ interface IJBSucker is IERC165 {
|
|
|
150
144
|
/// @return A `JBDenominatedAmount` with the converted value.
|
|
151
145
|
function peerChainSurplusOf(uint256 decimals, uint256 currency) external view returns (JBDenominatedAmount memory);
|
|
152
146
|
|
|
147
|
+
/// @notice The last known total token supply on the peer chain, updated each time a bridge message is received.
|
|
148
|
+
/// @dev Used by data hooks to compute `effectiveTotalSupply = localSupply + sum(peerChainTotalSupply)` across all
|
|
149
|
+
/// suckers, preventing cash out tax bypass on chains where a holder dominates the local supply.
|
|
150
|
+
/// @return The peer chain's total supply.
|
|
151
|
+
function peerChainTotalSupply() external view returns (uint256);
|
|
152
|
+
|
|
153
153
|
/// @notice The ID of the project on the local chain that this sucker is associated with.
|
|
154
154
|
/// @return The project ID.
|
|
155
155
|
function projectId() external view returns (uint256);
|
|
@@ -159,6 +159,11 @@ interface IJBSucker is IERC165 {
|
|
|
159
159
|
/// @return The remote token info.
|
|
160
160
|
function remoteTokenFor(address token) external view returns (JBRemoteToken memory);
|
|
161
161
|
|
|
162
|
+
/// @notice The freshness key of the latest accepted peer-chain economic snapshot.
|
|
163
|
+
/// @dev Higher values are fresher. The key is source-chain monotonic, not a value magnitude.
|
|
164
|
+
/// @return The latest peer-chain snapshot freshness key.
|
|
165
|
+
function snapshotTimestamp() external view returns (uint256);
|
|
166
|
+
|
|
162
167
|
/// @notice The current deprecation state of this sucker.
|
|
163
168
|
/// @return The sucker state.
|
|
164
169
|
function state() external view returns (JBSuckerState);
|