@bananapus/suckers-v6 0.0.58 → 0.0.60
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 +1 -1
- package/src/JBCCIPSucker.sol +40 -0
- package/src/JBSucker.sol +60 -53
- package/src/JBSuckerRegistry.sol +12 -0
- package/src/JBSwapCCIPSucker.sol +22 -31
- package/src/interfaces/IJBSucker.sol +12 -0
- package/src/interfaces/IJBSuckerRegistry.sol +8 -0
- package/src/libraries/JBSwapPoolLib.sol +43 -19
- package/src/structs/JBConversionRate.sol +12 -0
- package/src/structs/JBPendingSwap.sol +12 -0
package/package.json
CHANGED
package/src/JBCCIPSucker.sol
CHANGED
|
@@ -38,7 +38,11 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
38
38
|
//*********************************************************************//
|
|
39
39
|
|
|
40
40
|
error JBCCIPSucker_InvalidRouter(address router);
|
|
41
|
+
error JBCCIPSucker_PositiveRootWithoutDelivery(uint256 rootAmount);
|
|
42
|
+
error JBCCIPSucker_UnderDeliveredAmount(uint256 delivered, uint256 rootAmount);
|
|
43
|
+
error JBCCIPSucker_UnexpectedDeliveredTokens(uint256 count);
|
|
41
44
|
error JBCCIPSucker_UnknownMessageType(uint8 messageType);
|
|
45
|
+
error JBCCIPSucker_WrongDeliveredToken(address delivered, address expected);
|
|
42
46
|
|
|
43
47
|
//*********************************************************************//
|
|
44
48
|
// ------------------------------ events ----------------------------- //
|
|
@@ -175,6 +179,42 @@ contract JBCCIPSucker is JBSucker, IAny2EVMMessageReceiver {
|
|
|
175
179
|
// Decode the root message from the payload.
|
|
176
180
|
JBMessageRoot memory root = abi.decode(payload, (JBMessageRoot));
|
|
177
181
|
|
|
182
|
+
// Cross-check the delivered tokens against the advertised root before recording anything.
|
|
183
|
+
//
|
|
184
|
+
// The send-side guarantees at most one entry in `destTokenAmounts`: length 0 for zero-value batches,
|
|
185
|
+
// length 1 for value-bearing batches. A compromised peer (or a malformed CCIP delivery) that violates
|
|
186
|
+
// these invariants would otherwise let `fromRemote` record a root advertising more value than was
|
|
187
|
+
// bridged, letting later claims mint project tokens against unrelated balance until the inbox runs dry.
|
|
188
|
+
// `JBSwapCCIPSucker.ccipReceive` already enforces equivalent reverts for the swap variant; mirror
|
|
189
|
+
// them here so both variants share a single defensive baseline.
|
|
190
|
+
uint256 deliveryCount = any2EvmMessage.destTokenAmounts.length;
|
|
191
|
+
if (deliveryCount > 1) {
|
|
192
|
+
revert JBCCIPSucker_UnexpectedDeliveredTokens(deliveryCount);
|
|
193
|
+
}
|
|
194
|
+
if (deliveryCount == 0) {
|
|
195
|
+
if (root.amount > 0) revert JBCCIPSucker_PositiveRootWithoutDelivery(root.amount);
|
|
196
|
+
} else {
|
|
197
|
+
Client.EVMTokenAmount calldata delivered = any2EvmMessage.destTokenAmounts[0];
|
|
198
|
+
|
|
199
|
+
// For NATIVE_TOKEN bridges the delivered ERC-20 is the wrapped native token (CCIP cannot transport
|
|
200
|
+
// raw native), so the token-identity check happens inside `unwrapReceivedTokens` against the
|
|
201
|
+
// router-reported wrapped native address. For everything else, the delivered token must equal the
|
|
202
|
+
// local mapped token the root advertises.
|
|
203
|
+
if (root.token != bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))) {
|
|
204
|
+
address expectedToken = _toAddress(root.token);
|
|
205
|
+
if (delivered.token != expectedToken) {
|
|
206
|
+
revert JBCCIPSucker_WrongDeliveredToken({delivered: delivered.token, expected: expectedToken});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The bridged amount must back at least the value the root advertises. A short delivery against a
|
|
211
|
+
// positive root is the structural twin of "no delivery + positive root" — both leave the inbox
|
|
212
|
+
// recording more claimable value than it actually holds.
|
|
213
|
+
if (delivered.amount < root.amount) {
|
|
214
|
+
revert JBCCIPSucker_UnderDeliveredAmount({delivered: delivered.amount, rootAmount: root.amount});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
178
218
|
// Only unwrap wrapped native token when the root targets native token (not when claiming it as ERC-20).
|
|
179
219
|
if (root.token == bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))) {
|
|
180
220
|
JBCCIPLib.unwrapReceivedTokens({
|
package/src/JBSucker.sol
CHANGED
|
@@ -90,6 +90,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
90
90
|
error JBSucker_UnexpectedMsgValue(uint256 value);
|
|
91
91
|
error JBSucker_ZeroBeneficiary(bytes32 beneficiary);
|
|
92
92
|
error JBSucker_ZeroERC20Token(uint256 projectId);
|
|
93
|
+
error JBSucker_ZeroProjectTokenCount();
|
|
93
94
|
|
|
94
95
|
//*********************************************************************//
|
|
95
96
|
// ------------------------- public constants ------------------------ //
|
|
@@ -140,6 +141,15 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
140
141
|
/// @notice The address of this contract's deployer.
|
|
141
142
|
address public override deployer;
|
|
142
143
|
|
|
144
|
+
/// @notice The keccak256 hash of the leaf data committed at execution time, keyed by `(terminalToken,
|
|
145
|
+
/// leafIndex)`. Beneficiary contracts (e.g. `JBReferralSplitHook`) use this to authenticate post-hoc
|
|
146
|
+
/// settlement when their `claim()` call was front-run by a direct external caller — they re-derive the
|
|
147
|
+
/// hash from the claim data they hold and compare. Returns `bytes32(0)` for unexecuted indices —
|
|
148
|
+
/// `_buildTreeHash` is pre-image-resistant so zero unambiguously means "not executed".
|
|
149
|
+
/// @custom:param token The token whose inbox tree contains the leaf.
|
|
150
|
+
/// @custom:param index The leaf's index in the inbox tree.
|
|
151
|
+
mapping(address token => mapping(uint256 index => bytes32)) public override executedLeafHashOf;
|
|
152
|
+
|
|
143
153
|
/// @notice The last known total token supply on the peer chain, updated each time a bridge message is received.
|
|
144
154
|
/// @dev Used by data hooks to compute `effectiveTotalSupply = localSupply + sum(peerChainTotalSupply)` across all
|
|
145
155
|
/// suckers, preventing cash out tax bypass on chains where a holder dominates the local supply.
|
|
@@ -544,6 +554,14 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
544
554
|
external
|
|
545
555
|
override
|
|
546
556
|
{
|
|
557
|
+
// Reject zero-token prepares. A zero-token prepare burns nothing and reclaims nothing, but it still inserts a
|
|
558
|
+
// leaf into the outbox tree and lets `toRemote` ship a zero-value bridge message — a permissionless way to
|
|
559
|
+
// inflate the per-token populated-nonce list on swap-CCIP suckers, taxing every legitimate claim with extra
|
|
560
|
+
// lookup work and eventually exceeding the block gas limit.
|
|
561
|
+
if (projectTokenCount == 0) {
|
|
562
|
+
revert JBSucker_ZeroProjectTokenCount();
|
|
563
|
+
}
|
|
564
|
+
|
|
547
565
|
// Make sure the beneficiary is not the zero address, as this would revert when minting on the remote chain.
|
|
548
566
|
if (beneficiary == bytes32(0)) {
|
|
549
567
|
revert JBSucker_ZeroBeneficiary({beneficiary: beneficiary});
|
|
@@ -925,36 +943,31 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
925
943
|
revert JBSucker_NoTerminalForToken({projectId: cachedProjectId, token: token});
|
|
926
944
|
}
|
|
927
945
|
|
|
928
|
-
//
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
946
|
+
// Native and ERC-20 differ only in (a) value attachment to the call, and (b) ERC-20 requires an
|
|
947
|
+
// allowance grant + post-transfer balance assertion to catch fee-on-transfer / non-conforming tokens.
|
|
948
|
+
// The terminal call itself is identical for both, so it lives outside the branch.
|
|
949
|
+
uint256 nativeValue;
|
|
950
|
+
uint256 balanceBefore;
|
|
951
|
+
bool isErc20 = token != JBConstants.NATIVE_TOKEN;
|
|
952
|
+
if (isErc20) {
|
|
953
|
+
balanceBefore = IERC20(token).balanceOf(address(this));
|
|
934
954
|
SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: amount});
|
|
955
|
+
} else {
|
|
956
|
+
nativeValue = amount;
|
|
957
|
+
}
|
|
935
958
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
});
|
|
959
|
+
terminal.addToBalanceOf{value: nativeValue}({
|
|
960
|
+
projectId: cachedProjectId,
|
|
961
|
+
token: token,
|
|
962
|
+
amount: amount,
|
|
963
|
+
shouldReturnHeldFees: false,
|
|
964
|
+
memo: "",
|
|
965
|
+
metadata: ""
|
|
966
|
+
});
|
|
945
967
|
|
|
946
|
-
|
|
968
|
+
if (isErc20) {
|
|
969
|
+
// Sanity check: catches fee-on-transfer / non-conforming ERC-20s that move less than `amount`.
|
|
947
970
|
assert(IERC20(token).balanceOf(address(this)) == balanceBefore - amount);
|
|
948
|
-
} else {
|
|
949
|
-
// If the token is the native token, send ETH with the call.
|
|
950
|
-
terminal.addToBalanceOf{value: amount}({
|
|
951
|
-
projectId: cachedProjectId,
|
|
952
|
-
token: token,
|
|
953
|
-
amount: amount,
|
|
954
|
-
shouldReturnHeldFees: false,
|
|
955
|
-
memo: "",
|
|
956
|
-
metadata: ""
|
|
957
|
-
});
|
|
958
971
|
}
|
|
959
972
|
}
|
|
960
973
|
|
|
@@ -1320,15 +1333,21 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
1320
1333
|
// Register the leaf as executed to prevent double-spending.
|
|
1321
1334
|
_executedFor[terminalToken].set(index);
|
|
1322
1335
|
|
|
1323
|
-
//
|
|
1324
|
-
|
|
1325
|
-
|
|
1336
|
+
// Compute the leaf hash once. It's used twice: stored in `executedLeafHashOf` (so beneficiary contracts
|
|
1337
|
+
// can authenticate post-hoc settlement when their `claim()` was front-run) and passed to
|
|
1338
|
+
// `_validateBranchRoot` for merkle verification. The bare executed bitmap proves "some leaf at index I
|
|
1339
|
+
// was executed" but not "which leaf"; storing the hash binds the index to the actual leaf content.
|
|
1340
|
+
bytes32 leafHash = _buildTreeHash({
|
|
1326
1341
|
projectTokenCount: projectTokenCount,
|
|
1327
1342
|
terminalTokenAmount: terminalTokenAmount,
|
|
1328
1343
|
beneficiary: beneficiary,
|
|
1329
|
-
metadata: metadata
|
|
1330
|
-
|
|
1331
|
-
|
|
1344
|
+
metadata: metadata
|
|
1345
|
+
});
|
|
1346
|
+
executedLeafHashOf[terminalToken][index] = leafHash;
|
|
1347
|
+
|
|
1348
|
+
// Calculate the root and compare it to the current inbox root.
|
|
1349
|
+
_validateBranchRoot({
|
|
1350
|
+
expectedRoot: _inboxOf[terminalToken].root, leafHash: leafHash, index: index, leaves: leaves
|
|
1332
1351
|
});
|
|
1333
1352
|
}
|
|
1334
1353
|
|
|
@@ -1336,17 +1355,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
1336
1355
|
/// @dev This is a virtual function to allow a tests to override the behavior, it should never be overwritten
|
|
1337
1356
|
/// otherwise.
|
|
1338
1357
|
/// @param expectedRoot The expected merkle root to validate against.
|
|
1339
|
-
/// @param
|
|
1340
|
-
/// @param terminalTokenAmount The amount of terminal tokens in the leaf.
|
|
1341
|
-
/// @param beneficiary The beneficiary address in the leaf (bytes32 for cross-VM compatibility).
|
|
1358
|
+
/// @param leafHash The precomputed leaf hash (`_buildTreeHash` output) for the leaf being validated.
|
|
1342
1359
|
/// @param index The index of the leaf in the merkle tree.
|
|
1343
1360
|
/// @param leaves The merkle branch proving the leaf's inclusion.
|
|
1344
1361
|
function _validateBranchRoot(
|
|
1345
1362
|
bytes32 expectedRoot,
|
|
1346
|
-
|
|
1347
|
-
uint256 terminalTokenAmount,
|
|
1348
|
-
bytes32 beneficiary,
|
|
1349
|
-
bytes32 metadata,
|
|
1363
|
+
bytes32 leafHash,
|
|
1350
1364
|
uint256 index,
|
|
1351
1365
|
bytes32[_TREE_DEPTH] calldata leaves
|
|
1352
1366
|
)
|
|
@@ -1355,16 +1369,7 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
1355
1369
|
{
|
|
1356
1370
|
// Calculate the root based on the leaf, the branch, and the index.
|
|
1357
1371
|
// Delegates to JBSuckerLib (via DELEGATECALL) to keep MerkleLib.branchRoot bytecode out of each sucker.
|
|
1358
|
-
bytes32 root = JBSuckerLib.computeBranchRoot({
|
|
1359
|
-
item: _buildTreeHash({
|
|
1360
|
-
projectTokenCount: projectTokenCount,
|
|
1361
|
-
terminalTokenAmount: terminalTokenAmount,
|
|
1362
|
-
beneficiary: beneficiary,
|
|
1363
|
-
metadata: metadata
|
|
1364
|
-
}),
|
|
1365
|
-
branch: leaves,
|
|
1366
|
-
index: index
|
|
1367
|
-
});
|
|
1372
|
+
bytes32 root = JBSuckerLib.computeBranchRoot({item: leafHash, branch: leaves, index: index});
|
|
1368
1373
|
|
|
1369
1374
|
// Revert if the computed root does not match the expected inbox root.
|
|
1370
1375
|
if (root != expectedRoot) {
|
|
@@ -1451,10 +1456,12 @@ abstract contract JBSucker is ERC2771Context, JBPermissioned, Initializable, ERC
|
|
|
1451
1456
|
// Calculate the root and compare it to the current outbox root.
|
|
1452
1457
|
_validateBranchRoot({
|
|
1453
1458
|
expectedRoot: _computeOutboxRoot(_outboxOf[terminalToken].tree),
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1459
|
+
leafHash: _buildTreeHash({
|
|
1460
|
+
projectTokenCount: projectTokenCount,
|
|
1461
|
+
terminalTokenAmount: terminalTokenAmount,
|
|
1462
|
+
beneficiary: beneficiary,
|
|
1463
|
+
metadata: metadata
|
|
1464
|
+
}),
|
|
1458
1465
|
index: index,
|
|
1459
1466
|
leaves: leaves
|
|
1460
1467
|
});
|
package/src/JBSuckerRegistry.sol
CHANGED
|
@@ -109,6 +109,18 @@ contract JBSuckerRegistry is ERC2771Context, Ownable, JBPermissioned, IJBSuckerR
|
|
|
109
109
|
// ------------------------- external views -------------------------- //
|
|
110
110
|
//*********************************************************************//
|
|
111
111
|
|
|
112
|
+
/// @notice All suckers for a project, INCLUDING deprecated entries that are no longer listed in `suckersOf`.
|
|
113
|
+
/// @dev Used by consumers that need to detect "has any sucker ever peered to chain X?" — e.g. to prevent
|
|
114
|
+
/// premature burn of bridgeable credit by `JBReferralSplitHook.burnUnbridgeableCreditFor`. Returns every key
|
|
115
|
+
/// from `_suckersOf[projectId]` regardless of active/deprecated state. Order matches the underlying
|
|
116
|
+
/// `EnumerableMap` iteration order (insertion order, with swap-and-pop semantics on removal — which this
|
|
117
|
+
/// registry doesn't trigger, since deprecation transitions to `_SUCKER_DEPRECATED` rather than deleting).
|
|
118
|
+
/// @param projectId The ID of the project to get the suckers of.
|
|
119
|
+
/// @return suckers The addresses of every sucker ever registered for `projectId`.
|
|
120
|
+
function allSuckersOf(uint256 projectId) external view override returns (address[] memory suckers) {
|
|
121
|
+
return _suckersOf[projectId].keys();
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
/// @notice Whether the given address is a sucker (active or deprecated) that was deployed through this registry for
|
|
113
125
|
/// the specified project. Used by controllers to authorize mint calls from suckers.
|
|
114
126
|
/// @param projectId The ID of the project to check for.
|
package/src/JBSwapCCIPSucker.sol
CHANGED
|
@@ -39,7 +39,9 @@ import {JBSwapPoolLib} from "./libraries/JBSwapPoolLib.sol";
|
|
|
39
39
|
|
|
40
40
|
// Local: structs (alphabetized).
|
|
41
41
|
import {JBClaim} from "./structs/JBClaim.sol";
|
|
42
|
+
import {JBConversionRate} from "./structs/JBConversionRate.sol";
|
|
42
43
|
import {JBMessageRoot} from "./structs/JBMessageRoot.sol";
|
|
44
|
+
import {JBPendingSwap} from "./structs/JBPendingSwap.sol";
|
|
43
45
|
import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
|
|
44
46
|
|
|
45
47
|
/// @notice A `JBCCIPSucker` extension that swaps between local and bridge tokens using the best
|
|
@@ -71,7 +73,7 @@ import {JBRemoteToken} from "./structs/JBRemoteToken.sol";
|
|
|
71
73
|
///
|
|
72
74
|
/// **Inbound swap resilience**: If a swap reverts during `ccipReceive` (due to insufficient
|
|
73
75
|
/// liquidity, stale TWAP observations, or extreme price impact), the CCIP message still succeeds.
|
|
74
|
-
/// The unswapped bridge tokens are stored in a `
|
|
76
|
+
/// The unswapped bridge tokens are stored in a `JBPendingSwap` and the merkle root is recorded
|
|
75
77
|
/// normally. Claims for the affected batch are gated until `retrySwap` is called successfully.
|
|
76
78
|
/// Anyone can call `retrySwap` once swap conditions improve.
|
|
77
79
|
///
|
|
@@ -136,31 +138,11 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
136
138
|
// ------------------- internal stored properties -------------------- //
|
|
137
139
|
//*********************************************************************//
|
|
138
140
|
|
|
139
|
-
/// @notice Bridge tokens from a failed inbound swap, stored for later retry via `retrySwap`.
|
|
140
|
-
/// @custom:member bridgeToken The bridge token received from CCIP.
|
|
141
|
-
/// @custom:member bridgeAmount Amount of bridge tokens to swap.
|
|
142
|
-
/// @custom:member leafTotal Original leaf-denomination total (for conversion rate).
|
|
143
|
-
struct PendingSwap {
|
|
144
|
-
address bridgeToken;
|
|
145
|
-
uint256 bridgeAmount;
|
|
146
|
-
uint256 leafTotal;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
141
|
/// @notice Pending (failed) inbound swaps, keyed by local token and batch nonce.
|
|
150
142
|
/// @dev Populated when `ccipReceive` swap fails; cleared when `retrySwap` succeeds.
|
|
151
143
|
/// @custom:param localToken The local token the swap targets.
|
|
152
144
|
/// @custom:param nonce The CCIP nonce identifying the batch.
|
|
153
|
-
mapping(address localToken => mapping(uint64 nonce =>
|
|
154
|
-
|
|
155
|
-
/// @notice Immutable conversion rate for one received root batch, keyed by nonce.
|
|
156
|
-
/// @dev Each batch stores its total leaf and local amounts. Individual claims compute their
|
|
157
|
-
/// scaled amount as `claimLeafAmount * localTotal / leafTotal` — no mutable state changes.
|
|
158
|
-
/// @custom:member leafTotal Total leaf-denomination (source chain) amount for this batch.
|
|
159
|
-
/// @custom:member localTotal Total local-denomination (after swap) amount for this batch.
|
|
160
|
-
struct ConversionRate {
|
|
161
|
-
uint256 leafTotal;
|
|
162
|
-
uint256 localTotal;
|
|
163
|
-
}
|
|
145
|
+
mapping(address localToken => mapping(uint64 nonce => JBPendingSwap)) public pendingSwapOf;
|
|
164
146
|
|
|
165
147
|
/// @notice End leaf index (exclusive) for each received root batch, keyed by token and nonce.
|
|
166
148
|
/// @custom:param token The local token address.
|
|
@@ -177,7 +159,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
177
159
|
/// @notice Conversion rate for each received root batch, keyed by token and nonce.
|
|
178
160
|
/// @custom:param token The local token address.
|
|
179
161
|
/// @custom:param nonce The CCIP nonce identifying the batch.
|
|
180
|
-
mapping(address token => mapping(uint64 nonce =>
|
|
162
|
+
mapping(address token => mapping(uint64 nonce => JBConversionRate)) internal _conversionRateOf;
|
|
181
163
|
|
|
182
164
|
/// @notice Count of populated batch nonces per token. Appended exactly once per batch in
|
|
183
165
|
/// `ccipReceive`, so it equals the number of received batches independent of CCIP ordering.
|
|
@@ -394,7 +376,15 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
394
376
|
// either a batch range (batchEnd > 0) or a conversion rate / pending swap recorded.
|
|
395
377
|
// Record the batch range so _findNonceForLeafIndex can resolve leaf ownership
|
|
396
378
|
// independently of nonce ordering. Each nonce is self-describing: [start, end).
|
|
397
|
-
|
|
379
|
+
//
|
|
380
|
+
// Only record metadata for batches that carry value (`leafTotal > 0`). The base-sucker
|
|
381
|
+
// `prepare` revert on zero `projectTokenCount` blocks the legitimate spam entry point,
|
|
382
|
+
// but a compromised peer or a `projectTokenCount = 1`-style bypass could still ship a
|
|
383
|
+
// zero-leaf root over the bridge. Skipping the metadata write here ensures such roots
|
|
384
|
+
// cannot inflate `_populatedNonceByIndex` and tax future `_findNonceForLeafIndex`
|
|
385
|
+
// walks. Roots with `leafTotal == 0` carry no claimable leaves, so there is nothing
|
|
386
|
+
// for `_findNonceForLeafIndex` to resolve against them.
|
|
387
|
+
if (batchEnd > 0 && leafTotal > 0) {
|
|
398
388
|
// Record this batch's half-open leaf range `[batchStart, batchEnd)`. Self-
|
|
399
389
|
// describing per-nonce — no implicit chain across nonces — so out-of-order
|
|
400
390
|
// delivery can still resolve a leaf to its batch.
|
|
@@ -421,7 +411,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
421
411
|
// Store pendingSwapOf for failed swaps now that nonce is validated.
|
|
422
412
|
if (swapFailed) {
|
|
423
413
|
pendingSwapOf[localToken][nonce] =
|
|
424
|
-
|
|
414
|
+
JBPendingSwap({bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal});
|
|
425
415
|
}
|
|
426
416
|
|
|
427
417
|
// Zero-output swap guard: When a swap succeeds but returns zero local tokens, the
|
|
@@ -435,11 +425,12 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
435
425
|
// the swap produced a positive local amount.
|
|
436
426
|
if (leafTotal > 0 && !swapFailed) {
|
|
437
427
|
if (localAmount == 0 && deliveredAmount > 0) {
|
|
438
|
-
pendingSwapOf[localToken][nonce] =
|
|
439
|
-
|
|
428
|
+
pendingSwapOf[localToken][nonce] = JBPendingSwap({
|
|
429
|
+
bridgeToken: deliveredToken, bridgeAmount: deliveredAmount, leafTotal: leafTotal
|
|
430
|
+
});
|
|
440
431
|
} else {
|
|
441
432
|
_conversionRateOf[localToken][nonce] =
|
|
442
|
-
|
|
433
|
+
JBConversionRate({leafTotal: leafTotal, localTotal: localAmount});
|
|
443
434
|
}
|
|
444
435
|
}
|
|
445
436
|
} else {
|
|
@@ -504,7 +495,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
504
495
|
}
|
|
505
496
|
_retrySwapLocked = true;
|
|
506
497
|
|
|
507
|
-
|
|
498
|
+
JBPendingSwap memory pending = pendingSwapOf[localToken][nonce];
|
|
508
499
|
if (pending.bridgeAmount == 0) {
|
|
509
500
|
revert JBSwapCCIPSucker_NoPendingSwap({
|
|
510
501
|
localToken: localToken, nonce: nonce, retrySwapLocked: _retrySwapLocked
|
|
@@ -515,7 +506,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
515
506
|
_executeSwapOrRevert({tokenIn: pending.bridgeToken, tokenOut: localToken, amount: pending.bridgeAmount});
|
|
516
507
|
|
|
517
508
|
// Update the conversion rate so claims can proceed, then clear the pending swap.
|
|
518
|
-
_conversionRateOf[localToken][nonce] =
|
|
509
|
+
_conversionRateOf[localToken][nonce] = JBConversionRate({leafTotal: pending.leafTotal, localTotal: localAmount});
|
|
519
510
|
delete pendingSwapOf[localToken][nonce];
|
|
520
511
|
|
|
521
512
|
_retrySwapLocked = false;
|
|
@@ -571,7 +562,7 @@ contract JBSwapCCIPSucker is JBCCIPSucker, IUnlockCallback, IUniswapV3SwapCallba
|
|
|
571
562
|
if (pendingSwapOf[token][nonce].bridgeAmount > 0) {
|
|
572
563
|
revert JBSwapCCIPSucker_SwapPending({nonce: nonce});
|
|
573
564
|
}
|
|
574
|
-
|
|
565
|
+
JBConversionRate storage rate = _conversionRateOf[token][nonce];
|
|
575
566
|
if (rate.leafTotal > 0) {
|
|
576
567
|
amount = amount * rate.localTotal / rate.leafTotal;
|
|
577
568
|
}
|
|
@@ -108,6 +108,18 @@ interface IJBSucker is IERC165 {
|
|
|
108
108
|
/// @return The deployer address.
|
|
109
109
|
function deployer() external view returns (address);
|
|
110
110
|
|
|
111
|
+
/// @notice The keccak256 hash of the leaf data committed at execution time, for the leaf at the given
|
|
112
|
+
/// `(terminalToken, index)`. Returns `bytes32(0)` for unexecuted indices.
|
|
113
|
+
/// @dev Beneficiary contracts (e.g. `JBReferralSplitHook`) use this to authenticate post-hoc settlement when
|
|
114
|
+
/// their `claim()` call was front-run by a direct external caller — they re-derive the hash from the claim
|
|
115
|
+
/// data they hold and compare. The hash is computed via `_buildTreeHash(projectTokenCount,
|
|
116
|
+
/// terminalTokenAmount, beneficiary, metadata)` and is pre-image-resistant, so zero unambiguously means
|
|
117
|
+
/// "not executed".
|
|
118
|
+
/// @param token The terminal token whose tree contains the leaf.
|
|
119
|
+
/// @param index The index of the leaf in the inbox tree.
|
|
120
|
+
/// @return hash The committed leaf hash (or `bytes32(0)` if unexecuted).
|
|
121
|
+
function executedLeafHashOf(address token, uint256 index) external view returns (bytes32 hash);
|
|
122
|
+
|
|
111
123
|
/// @notice The inbox merkle tree root for a given token.
|
|
112
124
|
/// @param token The local terminal token.
|
|
113
125
|
/// @return The inbox tree root.
|
|
@@ -54,6 +54,14 @@ interface IJBSuckerRegistry {
|
|
|
54
54
|
/// @return The projects contract.
|
|
55
55
|
function PROJECTS() external view returns (IJBProjects);
|
|
56
56
|
|
|
57
|
+
/// @notice All suckers for a project, INCLUDING deprecated entries that are no longer listed in `suckersOf`.
|
|
58
|
+
/// @dev Used by consumers that need to detect "has any sucker ever peered to chain X?" — e.g. to prevent
|
|
59
|
+
/// premature burn of bridgeable credit by `JBReferralSplitHook.burnUnbridgeableCreditFor`. Returns every key
|
|
60
|
+
/// from `_suckersOf[projectId]` regardless of active/deprecated state.
|
|
61
|
+
/// @param projectId The ID of the project.
|
|
62
|
+
/// @return The addresses of every sucker ever registered for `projectId`.
|
|
63
|
+
function allSuckersOf(uint256 projectId) external view returns (address[] memory);
|
|
64
|
+
|
|
57
65
|
/// @notice Returns true if the specified sucker belongs to the specified project and was deployed through this
|
|
58
66
|
/// registry.
|
|
59
67
|
/// @param projectId The ID of the project to check for.
|
|
@@ -619,7 +619,14 @@ library JBSwapPoolLib {
|
|
|
619
619
|
});
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
-
/// @notice Get a V4 quote with dynamic slippage. Hooked pools must serve TWAP
|
|
622
|
+
/// @notice Get a V4 quote with dynamic slippage. Hooked pools must serve TWAP for BOTH the price tick and the
|
|
623
|
+
/// liquidity input into sigmoid slippage; hookless pools fall back to spot for both.
|
|
624
|
+
/// @dev Matches the buyback hook's TWAP pattern (`JBSwapLib.getQuoteFromOracle`): for hooked routes we derive
|
|
625
|
+
/// `harmonicMeanLiquidity` from the oracle's `secondsPerLiquidityCumulativeX128s`, not from
|
|
626
|
+
/// `PoolManager.getLiquidity` (which returns current spot and is JIT-LP-manipulable across a single block).
|
|
627
|
+
/// Sigmoid slippage tolerance is driven by `amountIn / liquidity`; feeding spot liquidity into a TWAP-derived
|
|
628
|
+
/// tick lets an LP shrink the denominator in the same block as a CCIP delivery, ballooning the tolerance to
|
|
629
|
+
/// `MAX_SLIPPAGE` (88%) for a one-shot per-batch immutable conversion rate that all claimers then inherit.
|
|
623
630
|
/// @param config The swap configuration (pool manager, wrapped native token addresses).
|
|
624
631
|
/// @param key The V4 pool key to quote against.
|
|
625
632
|
/// @param normalizedTokenIn The normalized input token address.
|
|
@@ -644,37 +651,54 @@ library JBSwapPoolLib {
|
|
|
644
651
|
PoolId id = key.toId();
|
|
645
652
|
|
|
646
653
|
{
|
|
647
|
-
// If the pool has a hook, require a TWAP from the geomean oracle.
|
|
654
|
+
// If the pool has a hook, require a TWAP from the geomean oracle for both price AND liquidity.
|
|
648
655
|
if (address(key.hooks) != address(0)) {
|
|
649
656
|
// Build the observation window: [_V4_TWAP_WINDOW seconds ago, now].
|
|
650
657
|
uint32[] memory secondsAgos = new uint32[](2);
|
|
651
658
|
secondsAgos[0] = _V4_TWAP_WINDOW;
|
|
652
659
|
secondsAgos[1] = 0;
|
|
653
660
|
|
|
654
|
-
// Read the TWAP
|
|
655
|
-
|
|
656
|
-
(int56[] memory tickCumulatives,) =
|
|
661
|
+
// Read both the TWAP tick and the seconds-per-liquidity series so liquidity is also time-averaged.
|
|
662
|
+
(int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) =
|
|
657
663
|
IGeomeanOracle(address(key.hooks)).observe({key: key, secondsAgos: secondsAgos});
|
|
658
|
-
if (tickCumulatives.length < 2) {
|
|
664
|
+
if (tickCumulatives.length < 2 || secondsPerLiquidityCumulativeX128s.length < 2) {
|
|
659
665
|
revert JBSwapPoolLib_InsufficientTwapHistory({
|
|
660
666
|
pool: address(key.hooks), availableWindow: tickCumulatives.length, requiredWindow: 2
|
|
661
667
|
});
|
|
662
668
|
}
|
|
663
669
|
|
|
664
|
-
// Compute the arithmetic mean tick from the cumulative tick difference
|
|
665
|
-
//
|
|
670
|
+
// Compute the arithmetic mean tick from the cumulative tick difference, rounding negative values
|
|
671
|
+
// toward negative infinity to match Uniswap's oracle pattern and the buyback hook.
|
|
672
|
+
int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
|
|
666
673
|
// forge-lint: disable-next-line(unsafe-typecast)
|
|
667
|
-
tick = int24(
|
|
674
|
+
tick = int24(tickCumulativesDelta / int56(int32(_V4_TWAP_WINDOW)));
|
|
675
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
676
|
+
if (tickCumulativesDelta < 0 && tickCumulativesDelta % int56(int32(_V4_TWAP_WINDOW)) != 0) {
|
|
677
|
+
tick--;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// Derive harmonic-mean liquidity from the seconds-per-liquidity delta. This is the same shape the
|
|
681
|
+
// buyback hook uses (`JBSwapLib.getQuoteFromOracle`) and resists JIT-LP liquidity removal in the
|
|
682
|
+
// delivery block — the manipulation has to persist across the full TWAP window to move the average.
|
|
683
|
+
uint160 secondsPerLiquidityDelta =
|
|
684
|
+
secondsPerLiquidityCumulativeX128s[1] - secondsPerLiquidityCumulativeX128s[0];
|
|
685
|
+
|
|
686
|
+
if (secondsPerLiquidityDelta > 0) {
|
|
687
|
+
// Safe: `(uint256(_V4_TWAP_WINDOW) << 128) / secondsPerLiquidityDelta` fits in uint128 because
|
|
688
|
+
// _V4_TWAP_WINDOW is at most a uint32 (~4.3B) and the divisor is > 0 in this branch.
|
|
689
|
+
// forge-lint: disable-next-line(unsafe-typecast)
|
|
690
|
+
liquidity = uint128((uint256(_V4_TWAP_WINDOW) << 128) / uint256(secondsPerLiquidityDelta));
|
|
691
|
+
}
|
|
692
|
+
// If `secondsPerLiquidityDelta == 0`, liquidity stays 0 and the no-liquidity revert below fires —
|
|
693
|
+
// refuse to quote a hooked route whose averaged liquidity is degenerate.
|
|
668
694
|
} else {
|
|
669
695
|
// Hookless V4 spot pools are only selected when no TWAP-capable route exists.
|
|
670
696
|
(, tick,,) = config.poolManager.getSlot0(id);
|
|
697
|
+
liquidity = config.poolManager.getLiquidity(id);
|
|
671
698
|
}
|
|
672
|
-
|
|
673
|
-
// Query the pool's current in-range liquidity.
|
|
674
|
-
liquidity = config.poolManager.getLiquidity(id);
|
|
675
699
|
}
|
|
676
700
|
|
|
677
|
-
// Revert if the pool has no in-range liquidity.
|
|
701
|
+
// Revert if the pool has no usable in-range liquidity (spot for hookless, TWAP-derived for hooked).
|
|
678
702
|
if (liquidity == 0) revert JBSwapPoolLib_NoLiquidity({pool: address(0), poolId: id});
|
|
679
703
|
|
|
680
704
|
// V4 uses address(0) for native ETH — compute quoting addresses inline to save stack slots.
|
|
@@ -864,8 +888,8 @@ library JBSwapPoolLib {
|
|
|
864
888
|
return _observationIsOldEnough({observationTimestamp: observationTimestamp, window: _DEFAULT_TWAP_WINDOW});
|
|
865
889
|
}
|
|
866
890
|
|
|
867
|
-
/// @notice Check whether a V4 hooked pool can return
|
|
868
|
-
/// @dev Hookless pools return false. Reverting hooks and hooks that return
|
|
891
|
+
/// @notice Check whether a V4 hooked pool can return TWAP price and liquidity for the required window.
|
|
892
|
+
/// @dev Hookless pools return false. Reverting hooks and hooks that return incomplete or degenerate oracle data
|
|
869
893
|
/// are treated as unusable for TWAP routing.
|
|
870
894
|
/// @param key The V4 pool key whose hook should be probed.
|
|
871
895
|
/// @return True if the hook can serve both the historical and current cumulative tick observations.
|
|
@@ -876,12 +900,12 @@ library JBSwapPoolLib {
|
|
|
876
900
|
secondsAgos[0] = _V4_TWAP_WINDOW;
|
|
877
901
|
secondsAgos[1] = 0;
|
|
878
902
|
|
|
879
|
-
// Pool discovery intentionally probes candidate hooks in a bounded pool list.
|
|
880
|
-
// is not needed for the history check.
|
|
903
|
+
// Pool discovery intentionally probes candidate hooks in a bounded pool list.
|
|
881
904
|
try IGeomeanOracle(address(key.hooks)).observe({key: key, secondsAgos: secondsAgos}) returns (
|
|
882
|
-
int56[] memory tickCumulatives, uint160[] memory
|
|
905
|
+
int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s
|
|
883
906
|
) {
|
|
884
|
-
|
|
907
|
+
if (tickCumulatives.length < 2 || secondsPerLiquidityCumulativeX128s.length < 2) return false;
|
|
908
|
+
return secondsPerLiquidityCumulativeX128s[1] > secondsPerLiquidityCumulativeX128s[0];
|
|
885
909
|
} catch {
|
|
886
910
|
return false;
|
|
887
911
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.0;
|
|
3
|
+
|
|
4
|
+
/// @notice Immutable conversion rate for one received root batch, keyed by nonce.
|
|
5
|
+
/// @dev Each batch stores its total leaf and local amounts. Individual claims compute their scaled amount as
|
|
6
|
+
/// `claimLeafAmount * localTotal / leafTotal` — no mutable state changes.
|
|
7
|
+
/// @custom:member leafTotal Total leaf-denomination (source chain) amount for this batch.
|
|
8
|
+
/// @custom:member localTotal Total local-denomination (after swap) amount for this batch.
|
|
9
|
+
struct JBConversionRate {
|
|
10
|
+
uint256 leafTotal;
|
|
11
|
+
uint256 localTotal;
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.0;
|
|
3
|
+
|
|
4
|
+
/// @notice Bridge tokens from a failed inbound swap, stored for later retry via `retrySwap`.
|
|
5
|
+
/// @custom:member bridgeToken The bridge token received from CCIP.
|
|
6
|
+
/// @custom:member bridgeAmount Amount of bridge tokens to swap.
|
|
7
|
+
/// @custom:member leafTotal Original leaf-denomination total (for conversion rate).
|
|
8
|
+
struct JBPendingSwap {
|
|
9
|
+
address bridgeToken;
|
|
10
|
+
uint256 bridgeAmount;
|
|
11
|
+
uint256 leafTotal;
|
|
12
|
+
}
|